loki-mode 6.37.3 → 6.37.4
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 +64 -21
- package/autonomy/run.sh +6 -5
- 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.4
|
|
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.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.37.
|
|
1
|
+
6.37.4
|
package/autonomy/loki
CHANGED
|
@@ -7788,6 +7788,36 @@ print(json.dumps({'id': manifest.id, 'dir': str(pipeline.migration_dir)}))
|
|
|
7788
7788
|
phases_to_run=("understand" "guardrail" "migrate" "verify")
|
|
7789
7789
|
fi
|
|
7790
7790
|
|
|
7791
|
+
# Validate prerequisite artifacts exist before running phases (#81)
|
|
7792
|
+
for p in "${phases_to_run[@]}"; do
|
|
7793
|
+
case "$p" in
|
|
7794
|
+
guardrail)
|
|
7795
|
+
if [ ! -f "${migration_dir}/docs/analysis.md" ] || [ ! -f "${migration_dir}/seams.json" ]; then
|
|
7796
|
+
echo -e "${RED}Error: Phase 'guardrail' requires artifacts from 'understand' phase${NC}"
|
|
7797
|
+
echo -e "${DIM} Missing: analysis.md or seams.json in ${migration_dir}${NC}"
|
|
7798
|
+
echo "Run: loki migrate ${codebase_path} --target ${target} --phase understand"
|
|
7799
|
+
return 1
|
|
7800
|
+
fi
|
|
7801
|
+
;;
|
|
7802
|
+
migrate)
|
|
7803
|
+
if [ ! -f "${migration_dir}/features.json" ]; then
|
|
7804
|
+
echo -e "${RED}Error: Phase 'migrate' requires artifacts from 'guardrail' phase${NC}"
|
|
7805
|
+
echo -e "${DIM} Missing: features.json in ${migration_dir}${NC}"
|
|
7806
|
+
echo "Run: loki migrate ${codebase_path} --target ${target} --phase guardrail"
|
|
7807
|
+
return 1
|
|
7808
|
+
fi
|
|
7809
|
+
;;
|
|
7810
|
+
verify)
|
|
7811
|
+
if [ ! -f "${migration_dir}/migration-plan.json" ]; then
|
|
7812
|
+
echo -e "${RED}Error: Phase 'verify' requires migration-plan.json from 'migrate' phase${NC}"
|
|
7813
|
+
echo -e "${DIM} Missing: migration-plan.json in ${migration_dir}${NC}"
|
|
7814
|
+
echo "Run: loki migrate ${codebase_path} --target ${target} --phase migrate"
|
|
7815
|
+
return 1
|
|
7816
|
+
fi
|
|
7817
|
+
;;
|
|
7818
|
+
esac
|
|
7819
|
+
done
|
|
7820
|
+
|
|
7791
7821
|
# Execute phases
|
|
7792
7822
|
for p in "${phases_to_run[@]}"; do
|
|
7793
7823
|
echo -e "${CYAN}[Phase: ${p}]${NC} Starting..."
|
|
@@ -12595,24 +12625,32 @@ cmd_council() {
|
|
|
12595
12625
|
|
|
12596
12626
|
if [ -f "$council_dir/state.json" ]; then
|
|
12597
12627
|
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
|
-
"
|
|
12628
|
+
import json, os, sys
|
|
12629
|
+
try:
|
|
12630
|
+
with open(os.environ['LOKI_COUNCIL_STATE']) as f:
|
|
12631
|
+
state = json.load(f)
|
|
12632
|
+
print(f\"Enabled: {state.get('initialized', False)}\")
|
|
12633
|
+
print(f\"Total votes: {state.get('total_votes', 0)}\")
|
|
12634
|
+
print(f\"Approve votes: {state.get('approve_votes', 0)}\")
|
|
12635
|
+
print(f\"Reject votes: {state.get('reject_votes', 0)}\")
|
|
12636
|
+
print(f\"Stagnation streak: {state.get('consecutive_no_change', 0)}\")
|
|
12637
|
+
print(f\"Done signals: {state.get('done_signals', 0)}\")
|
|
12638
|
+
print(f\"Last check: iteration {state.get('last_check_iteration', 'none')}\")
|
|
12639
|
+
verdicts = state.get('verdicts', [])
|
|
12640
|
+
if verdicts:
|
|
12641
|
+
print(f\"\nRecent verdicts:\")
|
|
12642
|
+
for v in verdicts[-5:]:
|
|
12643
|
+
print(f\" Iteration {v['iteration']}: {v['result']} ({v['approve']} approve / {v['reject']} reject)\")
|
|
12644
|
+
else:
|
|
12645
|
+
print(f\"\nNo verdicts yet\")
|
|
12646
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
12647
|
+
print(f'Error: Council state file is corrupted or invalid: {e}', file=sys.stderr)
|
|
12648
|
+
print('Delete .loki/council/state.json and restart the session to reset.')
|
|
12649
|
+
sys.exit(1)
|
|
12650
|
+
except Exception as e:
|
|
12651
|
+
print(f'Error reading council state: {e}', file=sys.stderr)
|
|
12652
|
+
sys.exit(1)
|
|
12653
|
+
" 2>&1
|
|
12616
12654
|
else
|
|
12617
12655
|
echo "Council state file not found"
|
|
12618
12656
|
fi
|
|
@@ -12623,9 +12661,14 @@ else:
|
|
|
12623
12661
|
|
|
12624
12662
|
if [ -f "$council_dir/state.json" ]; then
|
|
12625
12663
|
LOKI_COUNCIL_STATE="$council_dir/state.json" python3 -c "
|
|
12626
|
-
import json, os
|
|
12627
|
-
|
|
12628
|
-
|
|
12664
|
+
import json, os, sys
|
|
12665
|
+
try:
|
|
12666
|
+
with open(os.environ['LOKI_COUNCIL_STATE']) as f:
|
|
12667
|
+
state = json.load(f)
|
|
12668
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
12669
|
+
print(f'Error: Council state file is corrupted: {e}', file=sys.stderr)
|
|
12670
|
+
print('Delete .loki/council/state.json and restart the session to reset.')
|
|
12671
|
+
sys.exit(1)
|
|
12629
12672
|
verdicts = state.get('verdicts', [])
|
|
12630
12673
|
if not verdicts:
|
|
12631
12674
|
print('No decisions recorded yet')
|
package/autonomy/run.sh
CHANGED
|
@@ -5502,10 +5502,11 @@ run_code_review() {
|
|
|
5502
5502
|
log_info "Selecting 3 specialist reviewers from pool..."
|
|
5503
5503
|
|
|
5504
5504
|
# Write diff/files to temp files for python to read (avoid env var size limits)
|
|
5505
|
+
# Use printf to prevent shell variable expansion in diff content (#78)
|
|
5505
5506
|
local diff_file="$review_dir/$review_id/diff.txt"
|
|
5506
5507
|
local files_file="$review_dir/$review_id/files.txt"
|
|
5507
|
-
|
|
5508
|
-
|
|
5508
|
+
printf '%s\n' "$diff_content" > "$diff_file"
|
|
5509
|
+
printf '%s\n' "$changed_files" > "$files_file"
|
|
5509
5510
|
|
|
5510
5511
|
# Select specialists via keyword scoring (python3 reads files, not env vars)
|
|
5511
5512
|
# Loads from agents/types.json when available, falls back to hardcoded pool (v6.7.0)
|
|
@@ -5881,11 +5882,11 @@ run_adversarial_testing() {
|
|
|
5881
5882
|
local changed_files
|
|
5882
5883
|
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
5884
|
|
|
5884
|
-
# Write analysis files
|
|
5885
|
+
# Write analysis files -- use printf to prevent shell variable expansion (#78)
|
|
5885
5886
|
local diff_file="$adversarial_dir/$test_id/diff.txt"
|
|
5886
5887
|
local files_file="$adversarial_dir/$test_id/files.txt"
|
|
5887
|
-
|
|
5888
|
-
|
|
5888
|
+
printf '%s\n' "$diff_content" > "$diff_file"
|
|
5889
|
+
printf '%s\n' "$changed_files" > "$files_file"
|
|
5889
5890
|
|
|
5890
5891
|
# Build adversarial prompt -- use heredoc with quoted delimiter to prevent
|
|
5891
5892
|
# 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