loki-mode 6.37.3 → 6.37.5
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 +98 -28
- package/autonomy/run.sh +22 -8
- package/dashboard/__init__.py +1 -1
- package/dashboard/auth.py +27 -11
- package/dashboard/server.py +145 -42
- 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.37.
|
|
6
|
+
# Loki Mode v6.37.5
|
|
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.37.
|
|
270
|
+
**v6.37.5 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.37.
|
|
1
|
+
6.37.5
|
package/autonomy/loki
CHANGED
|
@@ -1365,6 +1365,15 @@ cmd_pause() {
|
|
|
1365
1365
|
fi
|
|
1366
1366
|
|
|
1367
1367
|
if is_session_running; then
|
|
1368
|
+
# Warn if running in perpetual mode where PAUSE is auto-cleared (#84)
|
|
1369
|
+
local current_mode="${LOKI_AUTONOMY_MODE:-perpetual}"
|
|
1370
|
+
local perpetual_flag="${LOKI_PERPETUAL_MODE:-false}"
|
|
1371
|
+
if [ "$current_mode" = "perpetual" ] || [ "$perpetual_flag" = "true" ]; then
|
|
1372
|
+
echo -e "${YELLOW}Warning: Session is running in perpetual mode.${NC}"
|
|
1373
|
+
echo -e "${YELLOW}PAUSE signals are auto-cleared in perpetual mode and will be ignored.${NC}"
|
|
1374
|
+
echo -e "Use ${CYAN}loki stop${NC} to halt a perpetual session, or switch autonomy mode first:"
|
|
1375
|
+
echo -e " ${DIM}loki config set autonomy_mode checkpoint${NC}"
|
|
1376
|
+
fi
|
|
1368
1377
|
touch "$LOKI_DIR/PAUSE"
|
|
1369
1378
|
# Emit session pause event
|
|
1370
1379
|
emit_event session cli pause "reason=user_requested"
|
|
@@ -4476,6 +4485,13 @@ cmd_watch() {
|
|
|
4476
4485
|
return 1
|
|
4477
4486
|
fi
|
|
4478
4487
|
|
|
4488
|
+
# Verify the PRD file is readable (#67)
|
|
4489
|
+
if [ ! -r "$prd_path" ]; then
|
|
4490
|
+
echo -e "${RED}PRD file is not readable: $prd_path${NC}"
|
|
4491
|
+
echo "Check file permissions: ls -la $prd_path"
|
|
4492
|
+
return 1
|
|
4493
|
+
fi
|
|
4494
|
+
|
|
4479
4495
|
# Resolve to absolute path
|
|
4480
4496
|
prd_path="$(cd "$(dirname "$prd_path")" && pwd)/$(basename "$prd_path")"
|
|
4481
4497
|
local prd_basename
|
|
@@ -5001,7 +5017,11 @@ cmd_config_set() {
|
|
|
5001
5017
|
;;
|
|
5002
5018
|
budget)
|
|
5003
5019
|
if ! echo "$value" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
|
|
5004
|
-
echo -e "${RED}Invalid budget: $value (expected: numeric USD amount)${NC}"; return 1
|
|
5020
|
+
echo -e "${RED}Invalid budget: $value (expected: positive numeric USD amount)${NC}"; return 1
|
|
5021
|
+
fi
|
|
5022
|
+
# Reject zero and negative values (#68)
|
|
5023
|
+
if echo "$value" | grep -qE '^0+(\.0+)?$'; then
|
|
5024
|
+
echo -e "${RED}Invalid budget: $value (must be greater than 0)${NC}"; return 1
|
|
5005
5025
|
fi
|
|
5006
5026
|
;;
|
|
5007
5027
|
model.planning|model.development|model.fast)
|
|
@@ -7788,6 +7808,36 @@ print(json.dumps({'id': manifest.id, 'dir': str(pipeline.migration_dir)}))
|
|
|
7788
7808
|
phases_to_run=("understand" "guardrail" "migrate" "verify")
|
|
7789
7809
|
fi
|
|
7790
7810
|
|
|
7811
|
+
# Validate prerequisite artifacts exist before running phases (#81)
|
|
7812
|
+
for p in "${phases_to_run[@]}"; do
|
|
7813
|
+
case "$p" in
|
|
7814
|
+
guardrail)
|
|
7815
|
+
if [ ! -f "${migration_dir}/docs/analysis.md" ] || [ ! -f "${migration_dir}/seams.json" ]; then
|
|
7816
|
+
echo -e "${RED}Error: Phase 'guardrail' requires artifacts from 'understand' phase${NC}"
|
|
7817
|
+
echo -e "${DIM} Missing: analysis.md or seams.json in ${migration_dir}${NC}"
|
|
7818
|
+
echo "Run: loki migrate ${codebase_path} --target ${target} --phase understand"
|
|
7819
|
+
return 1
|
|
7820
|
+
fi
|
|
7821
|
+
;;
|
|
7822
|
+
migrate)
|
|
7823
|
+
if [ ! -f "${migration_dir}/features.json" ]; then
|
|
7824
|
+
echo -e "${RED}Error: Phase 'migrate' requires artifacts from 'guardrail' phase${NC}"
|
|
7825
|
+
echo -e "${DIM} Missing: features.json in ${migration_dir}${NC}"
|
|
7826
|
+
echo "Run: loki migrate ${codebase_path} --target ${target} --phase guardrail"
|
|
7827
|
+
return 1
|
|
7828
|
+
fi
|
|
7829
|
+
;;
|
|
7830
|
+
verify)
|
|
7831
|
+
if [ ! -f "${migration_dir}/migration-plan.json" ]; then
|
|
7832
|
+
echo -e "${RED}Error: Phase 'verify' requires migration-plan.json from 'migrate' phase${NC}"
|
|
7833
|
+
echo -e "${DIM} Missing: migration-plan.json in ${migration_dir}${NC}"
|
|
7834
|
+
echo "Run: loki migrate ${codebase_path} --target ${target} --phase migrate"
|
|
7835
|
+
return 1
|
|
7836
|
+
fi
|
|
7837
|
+
;;
|
|
7838
|
+
esac
|
|
7839
|
+
done
|
|
7840
|
+
|
|
7791
7841
|
# Execute phases
|
|
7792
7842
|
for p in "${phases_to_run[@]}"; do
|
|
7793
7843
|
echo -e "${CYAN}[Phase: ${p}]${NC} Starting..."
|
|
@@ -12595,24 +12645,32 @@ cmd_council() {
|
|
|
12595
12645
|
|
|
12596
12646
|
if [ -f "$council_dir/state.json" ]; then
|
|
12597
12647
|
LOKI_COUNCIL_STATE="$council_dir/state.json" python3 -c "
|
|
12598
|
-
import json, os
|
|
12599
|
-
|
|
12600
|
-
|
|
12601
|
-
|
|
12602
|
-
print(f\"
|
|
12603
|
-
print(f\"
|
|
12604
|
-
print(f\"
|
|
12605
|
-
print(f\"
|
|
12606
|
-
print(f\"
|
|
12607
|
-
print(f\"
|
|
12608
|
-
|
|
12609
|
-
|
|
12610
|
-
|
|
12611
|
-
|
|
12612
|
-
|
|
12613
|
-
|
|
12614
|
-
|
|
12615
|
-
"
|
|
12648
|
+
import json, os, sys
|
|
12649
|
+
try:
|
|
12650
|
+
with open(os.environ['LOKI_COUNCIL_STATE']) as f:
|
|
12651
|
+
state = json.load(f)
|
|
12652
|
+
print(f\"Enabled: {state.get('initialized', False)}\")
|
|
12653
|
+
print(f\"Total votes: {state.get('total_votes', 0)}\")
|
|
12654
|
+
print(f\"Approve votes: {state.get('approve_votes', 0)}\")
|
|
12655
|
+
print(f\"Reject votes: {state.get('reject_votes', 0)}\")
|
|
12656
|
+
print(f\"Stagnation streak: {state.get('consecutive_no_change', 0)}\")
|
|
12657
|
+
print(f\"Done signals: {state.get('done_signals', 0)}\")
|
|
12658
|
+
print(f\"Last check: iteration {state.get('last_check_iteration', 'none')}\")
|
|
12659
|
+
verdicts = state.get('verdicts', [])
|
|
12660
|
+
if verdicts:
|
|
12661
|
+
print(f\"\nRecent verdicts:\")
|
|
12662
|
+
for v in verdicts[-5:]:
|
|
12663
|
+
print(f\" Iteration {v['iteration']}: {v['result']} ({v['approve']} approve / {v['reject']} reject)\")
|
|
12664
|
+
else:
|
|
12665
|
+
print(f\"\nNo verdicts yet\")
|
|
12666
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
12667
|
+
print(f'Error: Council state file is corrupted or invalid: {e}', file=sys.stderr)
|
|
12668
|
+
print('Delete .loki/council/state.json and restart the session to reset.')
|
|
12669
|
+
sys.exit(1)
|
|
12670
|
+
except Exception as e:
|
|
12671
|
+
print(f'Error reading council state: {e}', file=sys.stderr)
|
|
12672
|
+
sys.exit(1)
|
|
12673
|
+
" 2>&1
|
|
12616
12674
|
else
|
|
12617
12675
|
echo "Council state file not found"
|
|
12618
12676
|
fi
|
|
@@ -12623,9 +12681,14 @@ else:
|
|
|
12623
12681
|
|
|
12624
12682
|
if [ -f "$council_dir/state.json" ]; then
|
|
12625
12683
|
LOKI_COUNCIL_STATE="$council_dir/state.json" python3 -c "
|
|
12626
|
-
import json, os
|
|
12627
|
-
|
|
12628
|
-
|
|
12684
|
+
import json, os, sys
|
|
12685
|
+
try:
|
|
12686
|
+
with open(os.environ['LOKI_COUNCIL_STATE']) as f:
|
|
12687
|
+
state = json.load(f)
|
|
12688
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
12689
|
+
print(f'Error: Council state file is corrupted: {e}', file=sys.stderr)
|
|
12690
|
+
print('Delete .loki/council/state.json and restart the session to reset.')
|
|
12691
|
+
sys.exit(1)
|
|
12629
12692
|
verdicts = state.get('verdicts', [])
|
|
12630
12693
|
if not verdicts:
|
|
12631
12694
|
print('No decisions recorded yet')
|
|
@@ -14674,33 +14737,40 @@ for a in agents:
|
|
|
14674
14737
|
echo -e "${DIM}Persona: ${persona:0:80}...${NC}"
|
|
14675
14738
|
echo ""
|
|
14676
14739
|
|
|
14677
|
-
# Invoke current provider
|
|
14740
|
+
# Invoke current provider and capture exit code (#72)
|
|
14678
14741
|
local provider="${LOKI_PROVIDER:-claude}"
|
|
14679
14742
|
if [ -f ".loki/state/provider" ]; then
|
|
14680
14743
|
provider=$(cat ".loki/state/provider" 2>/dev/null)
|
|
14681
14744
|
fi
|
|
14682
14745
|
|
|
14746
|
+
local agent_exit=0
|
|
14683
14747
|
case "$provider" in
|
|
14684
14748
|
claude)
|
|
14685
|
-
claude -p "$full_prompt" 2>&1
|
|
14749
|
+
claude -p "$full_prompt" 2>&1 || agent_exit=$?
|
|
14686
14750
|
;;
|
|
14687
14751
|
codex)
|
|
14688
|
-
codex exec --full-auto "$full_prompt" 2>&1
|
|
14752
|
+
codex exec --full-auto "$full_prompt" 2>&1 || agent_exit=$?
|
|
14689
14753
|
;;
|
|
14690
14754
|
gemini)
|
|
14691
|
-
gemini --approval-mode=yolo "$full_prompt" 2>&1
|
|
14755
|
+
gemini --approval-mode=yolo "$full_prompt" 2>&1 || agent_exit=$?
|
|
14692
14756
|
;;
|
|
14693
14757
|
cline)
|
|
14694
|
-
cline -y "$full_prompt" 2>&1
|
|
14758
|
+
cline -y "$full_prompt" 2>&1 || agent_exit=$?
|
|
14695
14759
|
;;
|
|
14696
14760
|
aider)
|
|
14697
|
-
aider --message "$full_prompt" --yes-always --no-auto-commits < /dev/null 2>&1
|
|
14761
|
+
aider --message "$full_prompt" --yes-always --no-auto-commits < /dev/null 2>&1 || agent_exit=$?
|
|
14698
14762
|
;;
|
|
14699
14763
|
*)
|
|
14700
14764
|
echo -e "${RED}Unknown provider: $provider${NC}"
|
|
14701
14765
|
return 1
|
|
14702
14766
|
;;
|
|
14703
14767
|
esac
|
|
14768
|
+
|
|
14769
|
+
if [ "$agent_exit" -ne 0 ]; then
|
|
14770
|
+
echo ""
|
|
14771
|
+
echo -e "${RED}Agent exited with code $agent_exit${NC}"
|
|
14772
|
+
return "$agent_exit"
|
|
14773
|
+
fi
|
|
14704
14774
|
;;
|
|
14705
14775
|
|
|
14706
14776
|
start)
|
package/autonomy/run.sh
CHANGED
|
@@ -1243,6 +1243,7 @@ detect_complexity() {
|
|
|
1243
1243
|
if [ -n "$prd_path" ] && [ -f "$prd_path" ]; then
|
|
1244
1244
|
local prd_words=$(wc -w < "$prd_path" | tr -d ' ')
|
|
1245
1245
|
local feature_count=0
|
|
1246
|
+
local prd_lines=$(wc -l < "$prd_path" | tr -d ' ')
|
|
1246
1247
|
|
|
1247
1248
|
# Detect PRD format and count features accordingly
|
|
1248
1249
|
if [[ "$prd_path" == *.json ]]; then
|
|
@@ -1262,14 +1263,23 @@ detect_complexity() {
|
|
|
1262
1263
|
feature_count=$(grep -c "^##\|^- \[" "$prd_path" 2>/dev/null || echo "0")
|
|
1263
1264
|
fi
|
|
1264
1265
|
|
|
1265
|
-
|
|
1266
|
+
# Count distinct sections (h2/h3 headers) for structural complexity (#74)
|
|
1267
|
+
local section_count=0
|
|
1268
|
+
if [[ "$prd_path" != *.json ]]; then
|
|
1269
|
+
section_count=$(grep -c "^##\|^###" "$prd_path" 2>/dev/null || echo "0")
|
|
1270
|
+
fi
|
|
1271
|
+
|
|
1272
|
+
# PRD complexity uses content length, feature count, AND structural depth (#74)
|
|
1273
|
+
# A PRD with multiple sections or substantial content is not "simple" even with few project files
|
|
1274
|
+
if [ "$prd_words" -lt 200 ] && [ "$feature_count" -lt 5 ] && [ "$section_count" -lt 3 ]; then
|
|
1266
1275
|
prd_complexity="simple"
|
|
1267
|
-
elif [ "$prd_words" -gt 1000 ] || [ "$feature_count" -gt 15 ]; then
|
|
1276
|
+
elif [ "$prd_words" -gt 1000 ] || [ "$feature_count" -gt 15 ] || [ "$section_count" -gt 10 ]; then
|
|
1268
1277
|
prd_complexity="complex"
|
|
1269
1278
|
fi
|
|
1270
1279
|
fi
|
|
1271
1280
|
|
|
1272
1281
|
# Determine final complexity
|
|
1282
|
+
# A non-simple PRD always prevents "simple" classification regardless of file count (#74)
|
|
1273
1283
|
if [ "$file_count" -le 5 ] && [ "$prd_complexity" = "simple" ] && \
|
|
1274
1284
|
[ "$has_external" = "false" ] && [ "$has_microservices" = "false" ]; then
|
|
1275
1285
|
DETECTED_COMPLEXITY="simple"
|
|
@@ -1280,7 +1290,7 @@ detect_complexity() {
|
|
|
1280
1290
|
DETECTED_COMPLEXITY="standard"
|
|
1281
1291
|
fi
|
|
1282
1292
|
|
|
1283
|
-
log_info "Detected complexity: $DETECTED_COMPLEXITY (files: $file_count, external: $has_external, microservices: $has_microservices)"
|
|
1293
|
+
log_info "Detected complexity: $DETECTED_COMPLEXITY (files: $file_count, prd: $prd_complexity, external: $has_external, microservices: $has_microservices)"
|
|
1284
1294
|
}
|
|
1285
1295
|
|
|
1286
1296
|
# Get phases based on complexity tier
|
|
@@ -2824,6 +2834,9 @@ init_loki_dir() {
|
|
|
2824
2834
|
mkdir -p .loki/artifacts/{releases,reports,backups}
|
|
2825
2835
|
mkdir -p .loki/memory/{ledgers,handoffs,learnings,episodic,semantic,skills}
|
|
2826
2836
|
mkdir -p .loki/metrics/{efficiency,rewards}
|
|
2837
|
+
# Clear stale metrics from previous sessions so loki metrics shows current run data (#75)
|
|
2838
|
+
rm -f .loki/metrics/efficiency/iteration-*.json 2>/dev/null || true
|
|
2839
|
+
rm -f .loki/metrics/rewards/*.json 2>/dev/null || true
|
|
2827
2840
|
mkdir -p .loki/rules
|
|
2828
2841
|
mkdir -p .loki/signals
|
|
2829
2842
|
|
|
@@ -5502,10 +5515,11 @@ run_code_review() {
|
|
|
5502
5515
|
log_info "Selecting 3 specialist reviewers from pool..."
|
|
5503
5516
|
|
|
5504
5517
|
# Write diff/files to temp files for python to read (avoid env var size limits)
|
|
5518
|
+
# Use printf to prevent shell variable expansion in diff content (#78)
|
|
5505
5519
|
local diff_file="$review_dir/$review_id/diff.txt"
|
|
5506
5520
|
local files_file="$review_dir/$review_id/files.txt"
|
|
5507
|
-
|
|
5508
|
-
|
|
5521
|
+
printf '%s\n' "$diff_content" > "$diff_file"
|
|
5522
|
+
printf '%s\n' "$changed_files" > "$files_file"
|
|
5509
5523
|
|
|
5510
5524
|
# Select specialists via keyword scoring (python3 reads files, not env vars)
|
|
5511
5525
|
# Loads from agents/types.json when available, falls back to hardcoded pool (v6.7.0)
|
|
@@ -5881,11 +5895,11 @@ run_adversarial_testing() {
|
|
|
5881
5895
|
local changed_files
|
|
5882
5896
|
changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "")
|
|
5883
5897
|
|
|
5884
|
-
# Write analysis files
|
|
5898
|
+
# Write analysis files -- use printf to prevent shell variable expansion (#78)
|
|
5885
5899
|
local diff_file="$adversarial_dir/$test_id/diff.txt"
|
|
5886
5900
|
local files_file="$adversarial_dir/$test_id/files.txt"
|
|
5887
|
-
|
|
5888
|
-
|
|
5901
|
+
printf '%s\n' "$diff_content" > "$diff_file"
|
|
5902
|
+
printf '%s\n' "$changed_files" > "$files_file"
|
|
5889
5903
|
|
|
5890
5904
|
# Build adversarial prompt -- use heredoc with quoted delimiter to prevent
|
|
5891
5905
|
# shell variable expansion in diff content (fixes #78)
|
package/dashboard/__init__.py
CHANGED
package/dashboard/auth.py
CHANGED
|
@@ -59,17 +59,29 @@ _SCOPE_HIERARCHY = {
|
|
|
59
59
|
if OIDC_ENABLED:
|
|
60
60
|
import logging as _logging
|
|
61
61
|
_logger = _logging.getLogger("loki.auth")
|
|
62
|
+
_pyjwt_available = False
|
|
63
|
+
try:
|
|
64
|
+
import jwt as _pyjwt_check # noqa: F401
|
|
65
|
+
from jwt import PyJWKClient as _PyJWKClient_check # noqa: F401
|
|
66
|
+
_pyjwt_available = True
|
|
67
|
+
except ImportError:
|
|
68
|
+
_pyjwt_available = False
|
|
69
|
+
|
|
62
70
|
if OIDC_SKIP_SIGNATURE_VERIFY:
|
|
63
71
|
_logger.critical(
|
|
64
72
|
"OIDC/SSO signature verification DISABLED (LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true). "
|
|
65
73
|
"This is INSECURE and allows forged JWTs. Only use for local testing. "
|
|
66
74
|
"For production, install PyJWT + cryptography and remove this env var."
|
|
67
75
|
)
|
|
76
|
+
elif _pyjwt_available:
|
|
77
|
+
_logger.info(
|
|
78
|
+
"OIDC/SSO enabled with PyJWT cryptographic signature verification (RS256/RS384/RS512)."
|
|
79
|
+
)
|
|
68
80
|
else:
|
|
69
|
-
_logger.
|
|
70
|
-
"OIDC/SSO enabled
|
|
71
|
-
"
|
|
72
|
-
"cryptography
|
|
81
|
+
_logger.critical(
|
|
82
|
+
"OIDC/SSO enabled but PyJWT is NOT installed. Tokens will be REJECTED "
|
|
83
|
+
"unless LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true is set. "
|
|
84
|
+
"Install PyJWT + cryptography: pip install PyJWT cryptography"
|
|
73
85
|
)
|
|
74
86
|
|
|
75
87
|
# OIDC JWKS cache (issuer URL -> (keys_dict, fetch_timestamp))
|
|
@@ -526,14 +538,18 @@ def validate_oidc_token(token_str: str) -> Optional[dict]:
|
|
|
526
538
|
"issuer": decoded.get("iss"),
|
|
527
539
|
}
|
|
528
540
|
except ImportError:
|
|
529
|
-
# PyJWT not installed --
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
541
|
+
# PyJWT not installed -- only allow claims-only path if explicitly opted in
|
|
542
|
+
if not OIDC_SKIP_SIGNATURE_VERIFY:
|
|
543
|
+
_auth_logger.error(
|
|
544
|
+
"OIDC token rejected: PyJWT not installed and "
|
|
545
|
+
"LOKI_OIDC_SKIP_SIGNATURE_VERIFY is not set. "
|
|
546
|
+
"Install PyJWT: pip install PyJWT cryptography"
|
|
547
|
+
)
|
|
548
|
+
return None
|
|
549
|
+
_auth_logger.warning(
|
|
550
|
+
"PyJWT not installed -- using claims-only validation "
|
|
551
|
+
"(LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true). This is INSECURE."
|
|
533
552
|
)
|
|
534
|
-
_auth_logger.warning(_warning_msg)
|
|
535
|
-
# Also print to stderr so operators notice even without log config
|
|
536
|
-
print(_warning_msg, file=sys.stderr)
|
|
537
553
|
except Exception as exc:
|
|
538
554
|
_auth_logger.error("PyJWT signature verification failed: %s", exc)
|
|
539
555
|
return None
|
package/dashboard/server.py
CHANGED
|
@@ -30,7 +30,7 @@ from fastapi import (
|
|
|
30
30
|
)
|
|
31
31
|
from fastapi.middleware.cors import CORSMiddleware
|
|
32
32
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
33
|
-
from pydantic import BaseModel, Field
|
|
33
|
+
from pydantic import BaseModel, Field, field_validator
|
|
34
34
|
from sqlalchemy import select, update, delete
|
|
35
35
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
36
36
|
from sqlalchemy.orm import selectinload
|
|
@@ -75,6 +75,27 @@ def _safe_int_env(name: str, default: int) -> int:
|
|
|
75
75
|
return default
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def _safe_json_read(path: _Path, default: Any = None) -> Any:
|
|
79
|
+
"""Read a JSON file with retry on partial/corrupt data from concurrent writes."""
|
|
80
|
+
for attempt in range(2):
|
|
81
|
+
try:
|
|
82
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
83
|
+
return json.loads(text)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
if attempt == 0:
|
|
86
|
+
time.sleep(0.1)
|
|
87
|
+
continue
|
|
88
|
+
return default
|
|
89
|
+
except (OSError, IOError):
|
|
90
|
+
return default
|
|
91
|
+
return default
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _safe_read_text(path: _Path) -> str:
|
|
95
|
+
"""Read a text file with UTF-8 encoding, replacing non-UTF-8 bytes."""
|
|
96
|
+
return open(path, encoding="utf-8", errors="replace").read()
|
|
97
|
+
|
|
98
|
+
|
|
78
99
|
# ---------------------------------------------------------------------------
|
|
79
100
|
# Simple in-memory rate limiter for control endpoints
|
|
80
101
|
# ---------------------------------------------------------------------------
|
|
@@ -98,12 +119,12 @@ class _RateLimiter:
|
|
|
98
119
|
for k in empty_keys:
|
|
99
120
|
del self._calls[k]
|
|
100
121
|
|
|
101
|
-
# Evict
|
|
122
|
+
# Evict least-recently-accessed keys if max_keys exceeded
|
|
102
123
|
if len(self._calls) > self._max_keys:
|
|
103
|
-
# Sort by
|
|
124
|
+
# Sort by last-access time (most recent timestamp), evict least recent
|
|
104
125
|
sorted_keys = sorted(
|
|
105
126
|
self._calls.items(),
|
|
106
|
-
key=lambda x:
|
|
127
|
+
key=lambda x: max(x[1]) if x[1] else 0
|
|
107
128
|
)
|
|
108
129
|
keys_to_remove = len(self._calls) - self._max_keys
|
|
109
130
|
for k, _ in sorted_keys[:keys_to_remove]:
|
|
@@ -123,12 +144,30 @@ logger = logging.getLogger(__name__)
|
|
|
123
144
|
|
|
124
145
|
|
|
125
146
|
# Pydantic schemas for API
|
|
147
|
+
def _sanitize_text_field(value: str) -> str:
|
|
148
|
+
"""Strip/reject control characters from text fields."""
|
|
149
|
+
import unicodedata
|
|
150
|
+
# Remove control characters (except common whitespace like space)
|
|
151
|
+
cleaned = "".join(
|
|
152
|
+
ch for ch in value if unicodedata.category(ch)[0] != "C" or ch in (" ",)
|
|
153
|
+
)
|
|
154
|
+
cleaned = cleaned.strip()
|
|
155
|
+
if not cleaned:
|
|
156
|
+
raise ValueError("Field must not be empty after removing control characters")
|
|
157
|
+
return cleaned
|
|
158
|
+
|
|
159
|
+
|
|
126
160
|
class ProjectCreate(BaseModel):
|
|
127
161
|
"""Schema for creating a project."""
|
|
128
162
|
name: str = Field(..., min_length=1, max_length=255)
|
|
129
163
|
description: Optional[str] = None
|
|
130
164
|
prd_path: Optional[str] = None
|
|
131
165
|
|
|
166
|
+
@field_validator("name")
|
|
167
|
+
@classmethod
|
|
168
|
+
def validate_name(cls, v: str) -> str:
|
|
169
|
+
return _sanitize_text_field(v)
|
|
170
|
+
|
|
132
171
|
|
|
133
172
|
class ProjectUpdate(BaseModel):
|
|
134
173
|
"""Schema for updating a project."""
|
|
@@ -165,6 +204,11 @@ class TaskCreate(BaseModel):
|
|
|
165
204
|
parent_task_id: Optional[int] = None
|
|
166
205
|
estimated_duration: Optional[int] = None
|
|
167
206
|
|
|
207
|
+
@field_validator("title")
|
|
208
|
+
@classmethod
|
|
209
|
+
def validate_title(cls, v: str) -> str:
|
|
210
|
+
return _sanitize_text_field(v)
|
|
211
|
+
|
|
168
212
|
|
|
169
213
|
class TaskUpdate(BaseModel):
|
|
170
214
|
"""Schema for updating a task."""
|
|
@@ -315,7 +359,7 @@ async def _push_loki_state_loop() -> None:
|
|
|
315
359
|
if mtime != last_mtime:
|
|
316
360
|
last_mtime = mtime
|
|
317
361
|
try:
|
|
318
|
-
raw =
|
|
362
|
+
raw = _safe_json_read(state_file, {})
|
|
319
363
|
# Transform to StatusResponse-compatible format
|
|
320
364
|
agents_list = raw.get("agents", [])
|
|
321
365
|
running_agents = len(agents_list) if isinstance(agents_list, list) else 0
|
|
@@ -515,10 +559,10 @@ async def get_status() -> StatusResponse:
|
|
|
515
559
|
pending_tasks = 0
|
|
516
560
|
running_agents = 0
|
|
517
561
|
|
|
518
|
-
# Read dashboard state
|
|
562
|
+
# Read dashboard state (with retry for concurrent writes)
|
|
519
563
|
if state_file.exists():
|
|
520
564
|
try:
|
|
521
|
-
state =
|
|
565
|
+
state = _safe_json_read(state_file, {})
|
|
522
566
|
phase = state.get("phase", "")
|
|
523
567
|
iteration = state.get("iteration", 0)
|
|
524
568
|
complexity = state.get("complexity", "standard")
|
|
@@ -561,7 +605,7 @@ async def get_status() -> StatusResponse:
|
|
|
561
605
|
# Also check session.json for skill-invoked sessions
|
|
562
606
|
if not running and session_file.exists():
|
|
563
607
|
try:
|
|
564
|
-
sd =
|
|
608
|
+
sd = _safe_json_read(session_file, {})
|
|
565
609
|
if sd.get("status") == "running":
|
|
566
610
|
running = True
|
|
567
611
|
except (json.JSONDecodeError, KeyError):
|
|
@@ -673,21 +717,41 @@ async def get_status() -> StatusResponse:
|
|
|
673
717
|
@app.get("/api/projects", response_model=list[ProjectResponse])
|
|
674
718
|
async def list_projects(
|
|
675
719
|
status: Optional[str] = Query(None),
|
|
720
|
+
limit: int = Query(default=50, ge=1, le=500),
|
|
721
|
+
offset: int = Query(default=0, ge=0),
|
|
676
722
|
db: AsyncSession = Depends(get_db),
|
|
677
723
|
) -> list[ProjectResponse]:
|
|
678
|
-
"""List
|
|
679
|
-
|
|
724
|
+
"""List projects with pagination. Does not eager-load tasks for efficiency."""
|
|
725
|
+
from sqlalchemy import func as sa_func
|
|
726
|
+
|
|
727
|
+
query = select(Project)
|
|
680
728
|
if status:
|
|
681
729
|
query = query.where(Project.status == status)
|
|
682
|
-
query = query.order_by(Project.created_at.desc())
|
|
730
|
+
query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
|
|
683
731
|
|
|
684
732
|
result = await db.execute(query)
|
|
685
733
|
projects = result.scalars().all()
|
|
686
734
|
|
|
735
|
+
# Batch-fetch task counts instead of N+1 eager loading
|
|
736
|
+
project_ids = [p.id for p in projects]
|
|
687
737
|
response = []
|
|
738
|
+
if project_ids:
|
|
739
|
+
count_query = (
|
|
740
|
+
select(
|
|
741
|
+
Task.project_id,
|
|
742
|
+
sa_func.count().label("total"),
|
|
743
|
+
sa_func.count().filter(Task.status == TaskStatus.DONE).label("done"),
|
|
744
|
+
)
|
|
745
|
+
.where(Task.project_id.in_(project_ids))
|
|
746
|
+
.group_by(Task.project_id)
|
|
747
|
+
)
|
|
748
|
+
count_result = await db.execute(count_query)
|
|
749
|
+
counts = {row.project_id: (row.total, row.done) for row in count_result}
|
|
750
|
+
else:
|
|
751
|
+
counts = {}
|
|
752
|
+
|
|
688
753
|
for project in projects:
|
|
689
|
-
|
|
690
|
-
completed_count = len([t for t in project.tasks if t.status == TaskStatus.DONE])
|
|
754
|
+
total, done = counts.get(project.id, (0, 0))
|
|
691
755
|
response.append(
|
|
692
756
|
ProjectResponse(
|
|
693
757
|
id=project.id,
|
|
@@ -697,8 +761,8 @@ async def list_projects(
|
|
|
697
761
|
status=project.status,
|
|
698
762
|
created_at=project.created_at,
|
|
699
763
|
updated_at=project.updated_at,
|
|
700
|
-
task_count=
|
|
701
|
-
completed_task_count=
|
|
764
|
+
task_count=total,
|
|
765
|
+
completed_task_count=done,
|
|
702
766
|
)
|
|
703
767
|
)
|
|
704
768
|
return response
|
|
@@ -1066,12 +1130,29 @@ async def create_task(
|
|
|
1066
1130
|
Task.project_id == task.project_id
|
|
1067
1131
|
)
|
|
1068
1132
|
)
|
|
1069
|
-
|
|
1133
|
+
parent = result.scalar_one_or_none()
|
|
1134
|
+
if not parent:
|
|
1070
1135
|
raise HTTPException(
|
|
1071
1136
|
status_code=400,
|
|
1072
1137
|
detail="Parent task not found or belongs to different project"
|
|
1073
1138
|
)
|
|
1074
1139
|
|
|
1140
|
+
# Detect circular reference: walk parent chain
|
|
1141
|
+
visited = set()
|
|
1142
|
+
current_parent_id = task.parent_task_id
|
|
1143
|
+
while current_parent_id is not None:
|
|
1144
|
+
if current_parent_id in visited:
|
|
1145
|
+
raise HTTPException(
|
|
1146
|
+
status_code=422,
|
|
1147
|
+
detail="Circular reference detected in parent task chain"
|
|
1148
|
+
)
|
|
1149
|
+
visited.add(current_parent_id)
|
|
1150
|
+
parent_result = await db.execute(
|
|
1151
|
+
select(Task.parent_task_id).where(Task.id == current_parent_id)
|
|
1152
|
+
)
|
|
1153
|
+
row = parent_result.scalar_one_or_none()
|
|
1154
|
+
current_parent_id = row if row else None
|
|
1155
|
+
|
|
1075
1156
|
db_task = Task(
|
|
1076
1157
|
project_id=task.project_id,
|
|
1077
1158
|
title=task.title,
|
|
@@ -1192,6 +1273,15 @@ async def delete_task(
|
|
|
1192
1273
|
})
|
|
1193
1274
|
|
|
1194
1275
|
|
|
1276
|
+
# Valid status transitions for task state machine
|
|
1277
|
+
_TASK_STATE_MACHINE: dict[TaskStatus, set[TaskStatus]] = {
|
|
1278
|
+
TaskStatus.BACKLOG: {TaskStatus.PENDING},
|
|
1279
|
+
TaskStatus.PENDING: {TaskStatus.IN_PROGRESS},
|
|
1280
|
+
TaskStatus.IN_PROGRESS: {TaskStatus.REVIEW, TaskStatus.DONE},
|
|
1281
|
+
TaskStatus.REVIEW: {TaskStatus.DONE, TaskStatus.IN_PROGRESS},
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
|
|
1195
1285
|
@app.post("/api/tasks/{task_id}/move", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
|
|
1196
1286
|
async def move_task(
|
|
1197
1287
|
task_id: int,
|
|
@@ -1208,6 +1298,18 @@ async def move_task(
|
|
|
1208
1298
|
raise HTTPException(status_code=404, detail="Task not found")
|
|
1209
1299
|
|
|
1210
1300
|
old_status = task.status
|
|
1301
|
+
|
|
1302
|
+
# Validate status transition
|
|
1303
|
+
if move.status != old_status:
|
|
1304
|
+
allowed = _TASK_STATE_MACHINE.get(old_status, set())
|
|
1305
|
+
if move.status not in allowed:
|
|
1306
|
+
raise HTTPException(
|
|
1307
|
+
status_code=422,
|
|
1308
|
+
detail=f"Invalid status transition: {old_status.value} -> {move.status.value}. "
|
|
1309
|
+
f"Allowed transitions from {old_status.value}: "
|
|
1310
|
+
f"{', '.join(s.value for s in allowed) if allowed else 'none'}",
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1211
1313
|
task.status = move.status
|
|
1212
1314
|
task.position = move.position
|
|
1213
1315
|
|
|
@@ -1252,8 +1354,9 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
1252
1354
|
# proxy access logs -- configure log sanitization for /ws in production.
|
|
1253
1355
|
# FastAPI Depends() is not supported on @app.websocket() routes.
|
|
1254
1356
|
|
|
1255
|
-
# Rate limit WebSocket connections by IP
|
|
1256
|
-
|
|
1357
|
+
# Rate limit WebSocket connections by IP (use unique key when client info unavailable)
|
|
1358
|
+
import uuid as _uuid
|
|
1359
|
+
client_ip = websocket.client.host if websocket.client else f"ws-{_uuid.uuid4().hex}"
|
|
1257
1360
|
if not _read_limiter.check(f"ws_{client_ip}"):
|
|
1258
1361
|
await websocket.close(code=1008) # Policy Violation
|
|
1259
1362
|
return
|
|
@@ -1447,7 +1550,13 @@ async def sync_registry():
|
|
|
1447
1550
|
if not _read_limiter.check("registry_sync"):
|
|
1448
1551
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
1449
1552
|
|
|
1450
|
-
|
|
1553
|
+
try:
|
|
1554
|
+
result = await asyncio.wait_for(
|
|
1555
|
+
asyncio.get_event_loop().run_in_executor(None, registry.sync_registry_with_discovery),
|
|
1556
|
+
timeout=30.0,
|
|
1557
|
+
)
|
|
1558
|
+
except asyncio.TimeoutError:
|
|
1559
|
+
raise HTTPException(status_code=504, detail="Registry sync timed out after 30 seconds")
|
|
1451
1560
|
return {
|
|
1452
1561
|
"added": result["added"],
|
|
1453
1562
|
"updated": result["updated"],
|
|
@@ -1815,11 +1924,14 @@ async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
|
|
|
1815
1924
|
except Exception:
|
|
1816
1925
|
pass
|
|
1817
1926
|
|
|
1818
|
-
# Fallback to JSON files
|
|
1927
|
+
# Fallback to JSON files -- use heapq to avoid sorting all files
|
|
1928
|
+
import heapq
|
|
1819
1929
|
ep_dir = _get_loki_dir() / "memory" / "episodic"
|
|
1820
1930
|
episodes = []
|
|
1821
1931
|
if ep_dir.exists():
|
|
1822
|
-
|
|
1932
|
+
all_files = ep_dir.glob("*.json")
|
|
1933
|
+
# nlargest by filename (timestamps sort lexicographically) avoids full sort
|
|
1934
|
+
files = heapq.nlargest(limit, all_files, key=lambda f: f.name)
|
|
1823
1935
|
for f in files:
|
|
1824
1936
|
try:
|
|
1825
1937
|
episodes.append(json.loads(f.read_text()))
|
|
@@ -3525,7 +3637,7 @@ async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_cu
|
|
|
3525
3637
|
file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime, tz=timezone.utc).strftime(
|
|
3526
3638
|
"%Y-%m-%dT%H:%M:%S"
|
|
3527
3639
|
)
|
|
3528
|
-
content = log_file
|
|
3640
|
+
content = _safe_read_text(log_file)
|
|
3529
3641
|
for raw_line in content.strip().split("\n")[-lines:]:
|
|
3530
3642
|
timestamp = ""
|
|
3531
3643
|
level = "info"
|
|
@@ -4310,7 +4422,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
|
|
|
4310
4422
|
if not log_file.exists():
|
|
4311
4423
|
return {"lines": []}
|
|
4312
4424
|
try:
|
|
4313
|
-
all_lines = log_file
|
|
4425
|
+
all_lines = _safe_read_text(log_file).splitlines()
|
|
4314
4426
|
return {"lines": all_lines[-lines:]}
|
|
4315
4427
|
except OSError:
|
|
4316
4428
|
return {"lines": []}
|
|
@@ -4573,25 +4685,16 @@ async def serve_index():
|
|
|
4573
4685
|
if os.path.isfile(index_path):
|
|
4574
4686
|
return FileResponse(index_path, media_type="text/html")
|
|
4575
4687
|
|
|
4576
|
-
# Return
|
|
4577
|
-
return
|
|
4578
|
-
content=
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
<p><strong>API Endpoints:</strong></p>
|
|
4587
|
-
<ul>
|
|
4588
|
-
<li><a href="/health">/health</a> - Health check</li>
|
|
4589
|
-
<li><a href="/docs">/docs</a> - API documentation</li>
|
|
4590
|
-
</ul>
|
|
4591
|
-
</body>
|
|
4592
|
-
</html>
|
|
4593
|
-
""",
|
|
4594
|
-
status_code=200
|
|
4688
|
+
# Return 503 when frontend files are not found
|
|
4689
|
+
return JSONResponse(
|
|
4690
|
+
content={
|
|
4691
|
+
"error": "dashboard_frontend_not_found",
|
|
4692
|
+
"detail": "The dashboard API is running, but the frontend files were not found. "
|
|
4693
|
+
"Run: cd dashboard-ui && npm run build",
|
|
4694
|
+
"api_docs": "/docs",
|
|
4695
|
+
"health": "/health",
|
|
4696
|
+
},
|
|
4697
|
+
status_code=503,
|
|
4595
4698
|
)
|
|
4596
4699
|
|
|
4597
4700
|
|
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED