loki-mode 6.62.1 → 6.63.0
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/run.sh +311 -22
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +11 -2
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/web-app/server.py +437 -43
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.63.0
|
|
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.63.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.
|
|
1
|
+
6.63.0
|
package/autonomy/run.sh
CHANGED
|
@@ -3517,10 +3517,66 @@ track_iteration_start() {
|
|
|
3517
3517
|
"provider=${PROVIDER_NAME:-claude}"
|
|
3518
3518
|
fi
|
|
3519
3519
|
|
|
3520
|
+
# Read next pending task for context (enrich iteration with PRD task details)
|
|
3521
|
+
local next_task_context=""
|
|
3522
|
+
if [[ -f ".loki/queue/pending.json" ]]; then
|
|
3523
|
+
next_task_context=$(python3 -c "
|
|
3524
|
+
import json
|
|
3525
|
+
try:
|
|
3526
|
+
with open('.loki/queue/pending.json') as f:
|
|
3527
|
+
tasks = json.load(f)
|
|
3528
|
+
if isinstance(tasks, dict):
|
|
3529
|
+
tasks = tasks.get('tasks', [])
|
|
3530
|
+
pending = [t for t in tasks if isinstance(t, dict) and t.get('status','pending') == 'pending']
|
|
3531
|
+
if pending:
|
|
3532
|
+
t = pending[0]
|
|
3533
|
+
print(json.dumps({
|
|
3534
|
+
'current_task': t.get('title',''),
|
|
3535
|
+
'description': t.get('description',''),
|
|
3536
|
+
'acceptance_criteria': t.get('acceptance_criteria', []),
|
|
3537
|
+
'user_story': t.get('user_story', ''),
|
|
3538
|
+
'source': t.get('source', ''),
|
|
3539
|
+
'project': t.get('project', '')
|
|
3540
|
+
}))
|
|
3541
|
+
except: pass
|
|
3542
|
+
" 2>/dev/null || true)
|
|
3543
|
+
fi
|
|
3544
|
+
|
|
3520
3545
|
# Create task entry (escape PRD path for safe JSON embedding)
|
|
3521
3546
|
local prd_escaped
|
|
3522
3547
|
prd_escaped=$(printf '%s' "${prd:-Codebase Analysis}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')
|
|
3523
|
-
|
|
3548
|
+
|
|
3549
|
+
# Build enriched task JSON with pending task context
|
|
3550
|
+
local task_json
|
|
3551
|
+
if [[ -n "$next_task_context" ]]; then
|
|
3552
|
+
task_json=$(python3 -c "
|
|
3553
|
+
import json, sys
|
|
3554
|
+
ctx = json.loads('''$next_task_context''')
|
|
3555
|
+
task = {
|
|
3556
|
+
'id': 'iteration-$iteration',
|
|
3557
|
+
'type': 'iteration',
|
|
3558
|
+
'title': ctx.get('current_task') or 'Iteration $iteration',
|
|
3559
|
+
'description': ctx.get('description') or 'PRD: ${prd_escaped}',
|
|
3560
|
+
'status': 'in_progress',
|
|
3561
|
+
'priority': 'medium',
|
|
3562
|
+
'startedAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
|
|
3563
|
+
'provider': '${PROVIDER_NAME:-claude}'
|
|
3564
|
+
}
|
|
3565
|
+
if ctx.get('acceptance_criteria'):
|
|
3566
|
+
task['acceptance_criteria'] = ctx['acceptance_criteria']
|
|
3567
|
+
if ctx.get('user_story'):
|
|
3568
|
+
task['user_story'] = ctx['user_story']
|
|
3569
|
+
if ctx.get('source'):
|
|
3570
|
+
task['source'] = ctx['source']
|
|
3571
|
+
if ctx.get('project'):
|
|
3572
|
+
task['project'] = ctx['project']
|
|
3573
|
+
print(json.dumps(task, indent=2))
|
|
3574
|
+
" 2>/dev/null)
|
|
3575
|
+
fi
|
|
3576
|
+
|
|
3577
|
+
# Fallback to basic task JSON if enrichment failed
|
|
3578
|
+
if [[ -z "$task_json" ]]; then
|
|
3579
|
+
task_json=$(cat <<EOF
|
|
3524
3580
|
{
|
|
3525
3581
|
"id": "$task_id",
|
|
3526
3582
|
"type": "iteration",
|
|
@@ -3533,6 +3589,7 @@ track_iteration_start() {
|
|
|
3533
3589
|
}
|
|
3534
3590
|
EOF
|
|
3535
3591
|
)
|
|
3592
|
+
fi
|
|
3536
3593
|
|
|
3537
3594
|
# Add to in-progress queue
|
|
3538
3595
|
# BUG-XC-003: Use flock for atomic queue modification
|
|
@@ -7906,11 +7963,12 @@ load_state() {
|
|
|
7906
7963
|
|
|
7907
7964
|
# Load tasks from queue files for prompt injection
|
|
7908
7965
|
# Supports both array format [...] and object format {"tasks": [...]}
|
|
7966
|
+
# Enhanced in v6.63.0 to include rich task details (description, acceptance criteria, user stories)
|
|
7909
7967
|
load_queue_tasks() {
|
|
7910
7968
|
local task_injection=""
|
|
7911
7969
|
|
|
7912
|
-
# Helper Python script to extract and format tasks
|
|
7913
|
-
# Handles both formats,
|
|
7970
|
+
# Helper Python script to extract and format tasks with rich details
|
|
7971
|
+
# Handles both formats, includes description, acceptance criteria, and user stories
|
|
7914
7972
|
local extract_script='
|
|
7915
7973
|
import json
|
|
7916
7974
|
import sys
|
|
@@ -7928,23 +7986,45 @@ def extract_tasks(filepath, prefix):
|
|
|
7928
7986
|
if not isinstance(task, dict):
|
|
7929
7987
|
continue
|
|
7930
7988
|
task_id = task.get("id") or "unknown"
|
|
7931
|
-
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7989
|
+
source = task.get("source", "")
|
|
7990
|
+
|
|
7991
|
+
# Rich PRD-sourced tasks (v6.63.0)
|
|
7992
|
+
if source == "prd" or task_id.startswith("prd-"):
|
|
7993
|
+
title = task.get("title", "Task")
|
|
7994
|
+
lines = [f"{prefix}[{i+1}] {task_id}: {title}"]
|
|
7995
|
+
desc = task.get("description", "")
|
|
7996
|
+
if desc and desc != title:
|
|
7997
|
+
# First 300 chars of description, normalized
|
|
7998
|
+
desc_short = desc.replace("\n", " ").replace("\r", "")[:300]
|
|
7999
|
+
if len(desc) > 300:
|
|
8000
|
+
desc_short += "..."
|
|
8001
|
+
lines.append(f" Description: {desc_short}")
|
|
8002
|
+
criteria = task.get("acceptance_criteria", [])
|
|
8003
|
+
if criteria:
|
|
8004
|
+
criteria_str = "; ".join(str(c) for c in criteria[:5])
|
|
8005
|
+
lines.append(f" Acceptance: {criteria_str}")
|
|
8006
|
+
story = task.get("user_story", "")
|
|
8007
|
+
if story:
|
|
8008
|
+
lines.append(f" User Story: {story}")
|
|
8009
|
+
results.append("\n".join(lines))
|
|
7937
8010
|
else:
|
|
7938
|
-
|
|
7939
|
-
|
|
7940
|
-
|
|
7941
|
-
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
8011
|
+
# Legacy format: extract action from payload
|
|
8012
|
+
task_type = task.get("type") or "unknown"
|
|
8013
|
+
payload = task.get("payload", {})
|
|
8014
|
+
if isinstance(payload, dict):
|
|
8015
|
+
action = payload.get("action") or payload.get("goal") or ""
|
|
8016
|
+
else:
|
|
8017
|
+
action = str(payload) if payload else ""
|
|
8018
|
+
# Also check top-level title/description for non-payload tasks
|
|
8019
|
+
if not action:
|
|
8020
|
+
action = task.get("title", task.get("description", ""))
|
|
8021
|
+
# Normalize: remove newlines, truncate to 500 chars
|
|
8022
|
+
action = str(action).replace("\n", " ").replace("\r", "")[:500]
|
|
8023
|
+
if len(str(action)) > 500:
|
|
8024
|
+
action += "..."
|
|
8025
|
+
results.append(f"{prefix}[{i+1}] id={task_id} type={task_type}: {action}")
|
|
8026
|
+
|
|
8027
|
+
return "\n".join(results)
|
|
7948
8028
|
except:
|
|
7949
8029
|
return ""
|
|
7950
8030
|
|
|
@@ -7954,11 +8034,11 @@ pending = extract_tasks(".loki/queue/pending.json", "PENDING")
|
|
|
7954
8034
|
|
|
7955
8035
|
output = []
|
|
7956
8036
|
if in_progress:
|
|
7957
|
-
output.append(f"IN-PROGRESS TASKS (EXECUTE THESE)
|
|
8037
|
+
output.append(f"IN-PROGRESS TASKS (EXECUTE THESE):\n{in_progress}")
|
|
7958
8038
|
if pending:
|
|
7959
|
-
output.append(f"PENDING
|
|
8039
|
+
output.append(f"PENDING:\n{pending}")
|
|
7960
8040
|
|
|
7961
|
-
print("
|
|
8041
|
+
print("\n---\n".join(output))
|
|
7962
8042
|
'
|
|
7963
8043
|
|
|
7964
8044
|
# First check in-progress tasks (highest priority)
|
|
@@ -8700,6 +8780,212 @@ MIROFISH_QUEUE_EOF
|
|
|
8700
8780
|
log_info "MiroFish queue population complete"
|
|
8701
8781
|
}
|
|
8702
8782
|
|
|
8783
|
+
# Populate task queue from plain PRD markdown (if no adapter populated tasks)
|
|
8784
|
+
# Extracts features/requirements from markdown structure into rich task entries
|
|
8785
|
+
populate_prd_queue() {
|
|
8786
|
+
local prd_file="${1:-}"
|
|
8787
|
+
if [[ -z "$prd_file" ]] || [[ ! -f "$prd_file" ]]; then
|
|
8788
|
+
return 0
|
|
8789
|
+
fi
|
|
8790
|
+
# Skip if already populated
|
|
8791
|
+
if [[ -f ".loki/queue/.prd-populated" ]]; then
|
|
8792
|
+
return 0
|
|
8793
|
+
fi
|
|
8794
|
+
# Skip if OpenSpec, BMAD, or MiroFish already populated tasks
|
|
8795
|
+
if [[ -f ".loki/queue/.openspec-populated" ]] || [[ -f ".loki/queue/.bmad-populated" ]] || [[ -f ".loki/queue/.mirofish-populated" ]]; then
|
|
8796
|
+
log_info "Task queue already populated by adapter, skipping PRD parsing"
|
|
8797
|
+
return 0
|
|
8798
|
+
fi
|
|
8799
|
+
|
|
8800
|
+
log_step "Parsing PRD into structured tasks..."
|
|
8801
|
+
mkdir -p ".loki/queue"
|
|
8802
|
+
|
|
8803
|
+
LOKI_PRD_FILE="$prd_file" python3 << 'PRD_PARSE_EOF'
|
|
8804
|
+
import json, re, os, sys
|
|
8805
|
+
|
|
8806
|
+
prd_path = os.environ.get("LOKI_PRD_FILE", "")
|
|
8807
|
+
if not prd_path or not os.path.isfile(prd_path):
|
|
8808
|
+
sys.exit(0)
|
|
8809
|
+
|
|
8810
|
+
with open(prd_path, "r", errors="replace") as f:
|
|
8811
|
+
content = f.read()
|
|
8812
|
+
|
|
8813
|
+
# Parse PRD structure
|
|
8814
|
+
sections = {}
|
|
8815
|
+
current_section = "Overview"
|
|
8816
|
+
current_content = []
|
|
8817
|
+
|
|
8818
|
+
for line in content.split("\n"):
|
|
8819
|
+
heading_match = re.match(r'^#{1,3}\s+(.+)', line)
|
|
8820
|
+
if heading_match:
|
|
8821
|
+
if current_content:
|
|
8822
|
+
sections[current_section] = "\n".join(current_content).strip()
|
|
8823
|
+
current_section = heading_match.group(1).strip()
|
|
8824
|
+
current_content = []
|
|
8825
|
+
else:
|
|
8826
|
+
current_content.append(line)
|
|
8827
|
+
if current_content:
|
|
8828
|
+
sections[current_section] = "\n".join(current_content).strip()
|
|
8829
|
+
|
|
8830
|
+
# Extract project name from first H1
|
|
8831
|
+
project_name = "Project"
|
|
8832
|
+
for line in content.split("\n"):
|
|
8833
|
+
m = re.match(r'^#\s+(.+)', line)
|
|
8834
|
+
if m:
|
|
8835
|
+
project_name = m.group(1).strip()
|
|
8836
|
+
break
|
|
8837
|
+
|
|
8838
|
+
# Find feature/requirement sections
|
|
8839
|
+
feature_keywords = [
|
|
8840
|
+
"features", "requirements", "key features", "core features",
|
|
8841
|
+
"functional requirements", "user stories", "deliverables",
|
|
8842
|
+
"scope", "functionality", "capabilities", "modules"
|
|
8843
|
+
]
|
|
8844
|
+
|
|
8845
|
+
# Extract features from bullet points in feature sections
|
|
8846
|
+
features = []
|
|
8847
|
+
for section_name, section_content in sections.items():
|
|
8848
|
+
is_feature_section = any(kw in section_name.lower() for kw in feature_keywords)
|
|
8849
|
+
if is_feature_section:
|
|
8850
|
+
# Extract numbered items or bullet points
|
|
8851
|
+
for line in section_content.split("\n"):
|
|
8852
|
+
line = line.strip()
|
|
8853
|
+
# Match: "1. Feature name" or "- Feature name" or "* Feature name"
|
|
8854
|
+
m = re.match(r'^(?:\d+[\.\)]\s*|\-\s+|\*\s+)(.+)', line)
|
|
8855
|
+
if m:
|
|
8856
|
+
feature_text = m.group(1).strip()
|
|
8857
|
+
if len(feature_text) > 10: # Skip very short lines
|
|
8858
|
+
features.append({
|
|
8859
|
+
"title": feature_text,
|
|
8860
|
+
"section": section_name,
|
|
8861
|
+
})
|
|
8862
|
+
|
|
8863
|
+
# If no bullet features found, extract from ## headings that look like features
|
|
8864
|
+
if not features:
|
|
8865
|
+
skip_sections = {"overview", "introduction", "summary", "target audience",
|
|
8866
|
+
"tech stack", "technology", "deployment", "timeline",
|
|
8867
|
+
"out of scope", "non-functional", "appendix", "references",
|
|
8868
|
+
"problem statement", "value proposition", "background"}
|
|
8869
|
+
for section_name, section_content in sections.items():
|
|
8870
|
+
if section_name.lower() not in skip_sections and len(section_content) > 20:
|
|
8871
|
+
features.append({
|
|
8872
|
+
"title": section_name,
|
|
8873
|
+
"section": "Requirements",
|
|
8874
|
+
})
|
|
8875
|
+
|
|
8876
|
+
if not features:
|
|
8877
|
+
print("No features extracted from PRD", file=sys.stderr)
|
|
8878
|
+
sys.exit(0)
|
|
8879
|
+
|
|
8880
|
+
# Build acceptance criteria from section content
|
|
8881
|
+
def extract_acceptance_criteria(section_name, sections):
|
|
8882
|
+
"""Extract testable criteria from section content."""
|
|
8883
|
+
criteria = []
|
|
8884
|
+
content = sections.get(section_name, "")
|
|
8885
|
+
for line in content.split("\n"):
|
|
8886
|
+
line = line.strip()
|
|
8887
|
+
if line.startswith(("- ", "* ", " - ", " * ")):
|
|
8888
|
+
text = re.sub(r'^[\-\*]\s+', '', line).strip()
|
|
8889
|
+
if len(text) > 5:
|
|
8890
|
+
criteria.append(text)
|
|
8891
|
+
# Also check for acceptance criteria section
|
|
8892
|
+
for key in ["acceptance criteria", "success criteria", "definition of done"]:
|
|
8893
|
+
for sname, scontent in sections.items():
|
|
8894
|
+
if key in sname.lower():
|
|
8895
|
+
for cline in scontent.split("\n"):
|
|
8896
|
+
cline = cline.strip()
|
|
8897
|
+
m = re.match(r'^(?:\d+[\.\)]\s*|\-\s+|\*\s+|\[.\]\s*)(.+)', cline)
|
|
8898
|
+
if m:
|
|
8899
|
+
criteria.append(m.group(1).strip())
|
|
8900
|
+
return criteria[:10] # Cap at 10
|
|
8901
|
+
|
|
8902
|
+
# Determine priority based on position (earlier = higher)
|
|
8903
|
+
def get_priority(index, total):
|
|
8904
|
+
if total <= 3:
|
|
8905
|
+
return "high"
|
|
8906
|
+
third = total / 3
|
|
8907
|
+
if index < third:
|
|
8908
|
+
return "high"
|
|
8909
|
+
elif index < 2 * third:
|
|
8910
|
+
return "medium"
|
|
8911
|
+
return "low"
|
|
8912
|
+
|
|
8913
|
+
# Build task queue entries
|
|
8914
|
+
pending_path = ".loki/queue/pending.json"
|
|
8915
|
+
existing = []
|
|
8916
|
+
if os.path.exists(pending_path):
|
|
8917
|
+
try:
|
|
8918
|
+
with open(pending_path, "r") as f:
|
|
8919
|
+
data = json.load(f)
|
|
8920
|
+
if isinstance(data, list):
|
|
8921
|
+
existing = data
|
|
8922
|
+
elif isinstance(data, dict) and "tasks" in data:
|
|
8923
|
+
existing = data["tasks"]
|
|
8924
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
8925
|
+
existing = []
|
|
8926
|
+
|
|
8927
|
+
existing_ids = {t.get("id") for t in existing if isinstance(t, dict)}
|
|
8928
|
+
added = 0
|
|
8929
|
+
|
|
8930
|
+
for i, feat in enumerate(features):
|
|
8931
|
+
task_id = f"prd-{i+1:03d}"
|
|
8932
|
+
if task_id in existing_ids:
|
|
8933
|
+
continue
|
|
8934
|
+
|
|
8935
|
+
criteria = extract_acceptance_criteria(feat["section"], sections)
|
|
8936
|
+
|
|
8937
|
+
# Build a rich description
|
|
8938
|
+
section_content = sections.get(feat["section"], "")
|
|
8939
|
+
desc_parts = [feat["title"]]
|
|
8940
|
+
if section_content and len(section_content) > len(feat["title"]):
|
|
8941
|
+
# Include relevant context (first 500 chars of section)
|
|
8942
|
+
desc_parts.append(section_content[:500])
|
|
8943
|
+
|
|
8944
|
+
task = {
|
|
8945
|
+
"id": task_id,
|
|
8946
|
+
"title": feat["title"],
|
|
8947
|
+
"description": "\n".join(desc_parts),
|
|
8948
|
+
"priority": get_priority(i, len(features)),
|
|
8949
|
+
"status": "pending",
|
|
8950
|
+
"source": "prd",
|
|
8951
|
+
"project": project_name,
|
|
8952
|
+
}
|
|
8953
|
+
|
|
8954
|
+
if criteria:
|
|
8955
|
+
task["acceptance_criteria"] = criteria
|
|
8956
|
+
|
|
8957
|
+
# Add user story format
|
|
8958
|
+
# Try to extract target audience for user story
|
|
8959
|
+
audience = "a user"
|
|
8960
|
+
for key in ["target audience", "users", "user personas", "audience"]:
|
|
8961
|
+
for sname in sections:
|
|
8962
|
+
if key in sname.lower():
|
|
8963
|
+
# Extract first line
|
|
8964
|
+
first_line = sections[sname].split("\n")[0].strip()
|
|
8965
|
+
if first_line:
|
|
8966
|
+
audience = first_line[:100]
|
|
8967
|
+
break
|
|
8968
|
+
|
|
8969
|
+
task["user_story"] = f"As {audience}, I want to {feat['title'].lower().rstrip('.')}, so that the product delivers its core value."
|
|
8970
|
+
|
|
8971
|
+
existing.append(task)
|
|
8972
|
+
added += 1
|
|
8973
|
+
|
|
8974
|
+
with open(pending_path, "w") as f:
|
|
8975
|
+
json.dump(existing, f, indent=2)
|
|
8976
|
+
|
|
8977
|
+
print(f"Extracted {added} tasks from PRD ({len(features)} features found)", file=sys.stderr)
|
|
8978
|
+
PRD_PARSE_EOF
|
|
8979
|
+
|
|
8980
|
+
if [[ $? -ne 0 ]]; then
|
|
8981
|
+
log_warn "Failed to parse PRD into tasks"
|
|
8982
|
+
return 0
|
|
8983
|
+
fi
|
|
8984
|
+
|
|
8985
|
+
touch ".loki/queue/.prd-populated"
|
|
8986
|
+
log_info "PRD task parsing complete"
|
|
8987
|
+
}
|
|
8988
|
+
|
|
8703
8989
|
#===============================================================================
|
|
8704
8990
|
# Main Autonomous Loop
|
|
8705
8991
|
#===============================================================================
|
|
@@ -8812,6 +9098,9 @@ run_autonomous() {
|
|
|
8812
9098
|
# Populate task queue from MiroFish advisory (if present, runs once)
|
|
8813
9099
|
populate_mirofish_queue
|
|
8814
9100
|
|
|
9101
|
+
# Populate task queue from PRD (if no adapters already populated, runs once)
|
|
9102
|
+
populate_prd_queue "$prd_path"
|
|
9103
|
+
|
|
8815
9104
|
# Check max iterations before starting
|
|
8816
9105
|
if check_max_iterations; then
|
|
8817
9106
|
log_error "Max iterations already reached. Reset with: rm .loki/autonomy-state.json"
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -1085,7 +1085,7 @@ async def list_tasks(
|
|
|
1085
1085
|
# Skip if already in all_tasks
|
|
1086
1086
|
if any(t["id"] == tid for t in all_tasks):
|
|
1087
1087
|
continue
|
|
1088
|
-
|
|
1088
|
+
task_entry = {
|
|
1089
1089
|
"id": tid,
|
|
1090
1090
|
"title": item.get("title", item.get("action", "Task")),
|
|
1091
1091
|
"description": item.get("description", ""),
|
|
@@ -1093,7 +1093,16 @@ async def list_tasks(
|
|
|
1093
1093
|
"priority": item.get("priority", "medium"),
|
|
1094
1094
|
"type": item.get("type", "task"),
|
|
1095
1095
|
"position": i,
|
|
1096
|
-
}
|
|
1096
|
+
}
|
|
1097
|
+
if item.get("acceptance_criteria"):
|
|
1098
|
+
task_entry["acceptance_criteria"] = item["acceptance_criteria"]
|
|
1099
|
+
if item.get("user_story"):
|
|
1100
|
+
task_entry["user_story"] = item["user_story"]
|
|
1101
|
+
if item.get("project"):
|
|
1102
|
+
task_entry["project"] = item["project"]
|
|
1103
|
+
if item.get("source"):
|
|
1104
|
+
task_entry["source"] = item["source"]
|
|
1105
|
+
all_tasks.append(task_entry)
|
|
1097
1106
|
except (json.JSONDecodeError, KeyError):
|
|
1098
1107
|
pass
|
|
1099
1108
|
|
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED
package/package.json
CHANGED
package/web-app/server.py
CHANGED
|
@@ -627,25 +627,32 @@ class DevServerManager:
|
|
|
627
627
|
subprocess.run(["docker", "--version"], capture_output=True, timeout=5)
|
|
628
628
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
629
629
|
break # Docker not installed -- fall through to other detection
|
|
630
|
-
# Parse compose file to detect exposed port
|
|
630
|
+
# Parse compose file to detect exposed port and enumerate services
|
|
631
631
|
port = 3000 # default
|
|
632
|
+
services_info: list = []
|
|
632
633
|
try:
|
|
633
634
|
import yaml
|
|
634
635
|
with open(root / compose_file) as f:
|
|
635
636
|
compose = yaml.safe_load(f)
|
|
636
637
|
if compose and "services" in compose:
|
|
637
|
-
for svc in compose["services"].
|
|
638
|
-
|
|
639
|
-
for p in ports:
|
|
638
|
+
for svc_name, svc in compose["services"].items():
|
|
639
|
+
svc_ports: list = []
|
|
640
|
+
for p in svc.get("ports", []):
|
|
640
641
|
p_str = str(p)
|
|
641
642
|
if ":" in p_str:
|
|
642
|
-
# Handle IP:host:container (e.g. "127.0.0.1:8080:80")
|
|
643
|
-
# and host:container (e.g. "8080:80")
|
|
644
643
|
parts = p_str.split(":")
|
|
645
|
-
host_port = parts[-2]
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
644
|
+
host_port = int(parts[-2])
|
|
645
|
+
svc_ports.append(host_port)
|
|
646
|
+
services_info.append({
|
|
647
|
+
"name": svc_name,
|
|
648
|
+
"ports": svc_ports,
|
|
649
|
+
"image": svc.get("image"),
|
|
650
|
+
"has_build": "build" in svc,
|
|
651
|
+
})
|
|
652
|
+
# Use the first exposed host port as the primary port
|
|
653
|
+
for svc_entry in services_info:
|
|
654
|
+
if svc_entry["ports"]:
|
|
655
|
+
port = svc_entry["ports"][0]
|
|
649
656
|
break
|
|
650
657
|
except ImportError:
|
|
651
658
|
# yaml not available -- fall back to regex parsing
|
|
@@ -654,11 +661,149 @@ class DevServerManager:
|
|
|
654
661
|
port_match = re.search(r'"?(\d+):(\d+)"?', content)
|
|
655
662
|
if port_match:
|
|
656
663
|
port = int(port_match.group(1))
|
|
664
|
+
# Extract service names via regex
|
|
665
|
+
for m in re.finditer(r'^ (\w[\w-]*):\s*$', content, re.MULTILINE):
|
|
666
|
+
services_info.append({"name": m.group(1), "ports": []})
|
|
657
667
|
except Exception:
|
|
658
668
|
pass
|
|
659
669
|
except Exception:
|
|
660
670
|
pass
|
|
661
|
-
|
|
671
|
+
result_dict: dict = {
|
|
672
|
+
"command": f"docker compose -f {compose_file} up --build",
|
|
673
|
+
"expected_port": port,
|
|
674
|
+
"framework": "docker",
|
|
675
|
+
}
|
|
676
|
+
if services_info:
|
|
677
|
+
result_dict["services"] = services_info
|
|
678
|
+
return result_dict
|
|
679
|
+
|
|
680
|
+
# -- Full-stack project detection (frontend + backend in subdirectories) --
|
|
681
|
+
frontend_dir_names = ["frontend", "client", "web", "app", "ui", "web-app", "webapp"]
|
|
682
|
+
backend_dir_names = ["backend", "server", "api", "service"]
|
|
683
|
+
|
|
684
|
+
frontend_dir: Optional[Path] = None
|
|
685
|
+
backend_dir: Optional[Path] = None
|
|
686
|
+
|
|
687
|
+
for d in frontend_dir_names:
|
|
688
|
+
candidate = root / d
|
|
689
|
+
if candidate.is_dir():
|
|
690
|
+
# Verify it is actually a frontend (has package.json or index.html)
|
|
691
|
+
if (candidate / "package.json").exists() or (candidate / "index.html").exists():
|
|
692
|
+
frontend_dir = candidate
|
|
693
|
+
break
|
|
694
|
+
|
|
695
|
+
for d in backend_dir_names:
|
|
696
|
+
candidate = root / d
|
|
697
|
+
if candidate.is_dir():
|
|
698
|
+
has_py = any(candidate.glob("*.py"))
|
|
699
|
+
has_pkg = (candidate / "package.json").exists()
|
|
700
|
+
has_go = (candidate / "go.mod").exists()
|
|
701
|
+
has_requirements = (candidate / "requirements.txt").exists()
|
|
702
|
+
has_cargo = (candidate / "Cargo.toml").exists()
|
|
703
|
+
if has_py or has_pkg or has_go or has_requirements or has_cargo:
|
|
704
|
+
backend_dir = candidate
|
|
705
|
+
break
|
|
706
|
+
|
|
707
|
+
if frontend_dir and backend_dir:
|
|
708
|
+
# Detect frontend framework and command
|
|
709
|
+
fe_cmd = "npm run dev"
|
|
710
|
+
fe_port = 3000
|
|
711
|
+
fe_framework = "node"
|
|
712
|
+
fe_pkg = frontend_dir / "package.json"
|
|
713
|
+
if fe_pkg.exists():
|
|
714
|
+
try:
|
|
715
|
+
pkg = json.loads(fe_pkg.read_text(errors="replace"))
|
|
716
|
+
fe_scripts = pkg.get("scripts", {})
|
|
717
|
+
fe_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
|
718
|
+
if "next" in fe_deps:
|
|
719
|
+
fe_framework = "next"
|
|
720
|
+
fe_port = 3000
|
|
721
|
+
fe_cmd = "npm run dev"
|
|
722
|
+
elif "vite" in fe_deps:
|
|
723
|
+
fe_framework = "vite"
|
|
724
|
+
fe_port = 5173
|
|
725
|
+
fe_cmd = "npm run dev"
|
|
726
|
+
elif "dev" in fe_scripts:
|
|
727
|
+
fe_cmd = "npm run dev"
|
|
728
|
+
elif "start" in fe_scripts:
|
|
729
|
+
fe_cmd = "npm start"
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
elif (frontend_dir / "index.html").exists():
|
|
733
|
+
fe_framework = "static"
|
|
734
|
+
fe_cmd = "python3 -m http.server 3000"
|
|
735
|
+
fe_port = 3000
|
|
736
|
+
|
|
737
|
+
# Detect backend framework and command
|
|
738
|
+
be_cmd: Optional[str] = None
|
|
739
|
+
be_port = 8000
|
|
740
|
+
be_framework = "unknown"
|
|
741
|
+
|
|
742
|
+
if (backend_dir / "manage.py").exists():
|
|
743
|
+
be_cmd = "python manage.py runserver"
|
|
744
|
+
be_port = 8000
|
|
745
|
+
be_framework = "django"
|
|
746
|
+
else:
|
|
747
|
+
for py_entry in ("app.py", "main.py", "server.py"):
|
|
748
|
+
py_file = backend_dir / py_entry
|
|
749
|
+
if py_file.exists():
|
|
750
|
+
try:
|
|
751
|
+
src = py_file.read_text(errors="replace")[:4096]
|
|
752
|
+
if "fastapi" in src.lower() or "FastAPI" in src:
|
|
753
|
+
module = py_entry[:-3]
|
|
754
|
+
be_cmd = f"uvicorn {module}:app --reload --port 8000"
|
|
755
|
+
be_port = 8000
|
|
756
|
+
be_framework = "fastapi"
|
|
757
|
+
break
|
|
758
|
+
if "flask" in src.lower() or "Flask" in src:
|
|
759
|
+
be_cmd = "flask run --port 5000"
|
|
760
|
+
be_port = 5000
|
|
761
|
+
be_framework = "flask"
|
|
762
|
+
break
|
|
763
|
+
except OSError:
|
|
764
|
+
pass
|
|
765
|
+
if be_cmd is None and (backend_dir / "package.json").exists():
|
|
766
|
+
try:
|
|
767
|
+
be_pkg = json.loads((backend_dir / "package.json").read_text(errors="replace"))
|
|
768
|
+
be_scripts = be_pkg.get("scripts", {})
|
|
769
|
+
be_deps = {**be_pkg.get("dependencies", {}), **be_pkg.get("devDependencies", {})}
|
|
770
|
+
if "express" in be_deps:
|
|
771
|
+
be_framework = "express"
|
|
772
|
+
else:
|
|
773
|
+
be_framework = "node"
|
|
774
|
+
be_port = 3001
|
|
775
|
+
if "dev" in be_scripts:
|
|
776
|
+
be_cmd = "npm run dev"
|
|
777
|
+
elif "start" in be_scripts:
|
|
778
|
+
be_cmd = "npm start"
|
|
779
|
+
except Exception:
|
|
780
|
+
pass
|
|
781
|
+
if be_cmd is None and (backend_dir / "go.mod").exists():
|
|
782
|
+
be_cmd = "go run ."
|
|
783
|
+
be_port = 8080
|
|
784
|
+
be_framework = "go"
|
|
785
|
+
if be_cmd is None and (backend_dir / "requirements.txt").exists():
|
|
786
|
+
# Generic Python backend with requirements.txt
|
|
787
|
+
for py_entry in ("app.py", "main.py", "server.py", "run.py"):
|
|
788
|
+
if (backend_dir / py_entry).exists():
|
|
789
|
+
be_cmd = f"python {py_entry}"
|
|
790
|
+
be_port = 8000
|
|
791
|
+
be_framework = "python"
|
|
792
|
+
break
|
|
793
|
+
|
|
794
|
+
if be_cmd:
|
|
795
|
+
return {
|
|
796
|
+
"framework": "full-stack",
|
|
797
|
+
"command": f"cd {backend_dir.name} && {be_cmd}",
|
|
798
|
+
"frontend_command": f"cd {frontend_dir.name} && {fe_cmd}",
|
|
799
|
+
"expected_port": fe_port,
|
|
800
|
+
"backend_port": be_port,
|
|
801
|
+
"multi_service": True,
|
|
802
|
+
"frontend_dir": str(frontend_dir),
|
|
803
|
+
"backend_dir": str(backend_dir),
|
|
804
|
+
"frontend_framework": fe_framework,
|
|
805
|
+
"backend_framework": be_framework,
|
|
806
|
+
}
|
|
662
807
|
|
|
663
808
|
pkg_json = root / "package.json"
|
|
664
809
|
if pkg_json.exists():
|
|
@@ -767,6 +912,36 @@ class DevServerManager:
|
|
|
767
912
|
return port
|
|
768
913
|
return None
|
|
769
914
|
|
|
915
|
+
def _install_pip_deps(self, project_path: Path, build_env: dict) -> None:
|
|
916
|
+
"""Install pip dependencies into a project venv (creates one if needed)."""
|
|
917
|
+
if not (project_path / "requirements.txt").exists():
|
|
918
|
+
return
|
|
919
|
+
venv_dir = None
|
|
920
|
+
for venv_name in ("venv", ".venv", "env"):
|
|
921
|
+
candidate = project_path / venv_name
|
|
922
|
+
if candidate.is_dir() and (candidate / "bin" / "pip").exists():
|
|
923
|
+
venv_dir = candidate
|
|
924
|
+
break
|
|
925
|
+
if venv_dir is None:
|
|
926
|
+
try:
|
|
927
|
+
subprocess.run(
|
|
928
|
+
[sys.executable, "-m", "venv", str(project_path / "venv")],
|
|
929
|
+
capture_output=True, timeout=60,
|
|
930
|
+
)
|
|
931
|
+
venv_dir = project_path / "venv"
|
|
932
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
933
|
+
logger.warning("venv creation failed: %s", exc)
|
|
934
|
+
pip_executable = str(venv_dir / "bin" / "pip") if venv_dir else "pip"
|
|
935
|
+
try:
|
|
936
|
+
subprocess.run(
|
|
937
|
+
[pip_executable, "install", "-r", "requirements.txt"],
|
|
938
|
+
cwd=str(project_path),
|
|
939
|
+
capture_output=True,
|
|
940
|
+
timeout=120,
|
|
941
|
+
)
|
|
942
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
943
|
+
logger.warning("pip install failed: %s", exc)
|
|
944
|
+
|
|
770
945
|
async def start(self, session_id: str, project_dir: str, command: Optional[str] = None) -> dict:
|
|
771
946
|
"""Start dev server. Auto-detect command if not provided."""
|
|
772
947
|
if session_id in self.servers:
|
|
@@ -792,15 +967,177 @@ class DevServerManager:
|
|
|
792
967
|
cmd_str = command or (detected["command"] if detected else "")
|
|
793
968
|
expected_port = detected["expected_port"] if detected else 3000
|
|
794
969
|
framework = detected["framework"] if detected else "unknown"
|
|
970
|
+
is_multi_service = detected.get("multi_service", False) if detected else False
|
|
971
|
+
|
|
972
|
+
build_env = {**os.environ}
|
|
973
|
+
build_env.update(_load_secrets())
|
|
974
|
+
|
|
975
|
+
# -- Multi-service full-stack startup --
|
|
976
|
+
if is_multi_service and not command:
|
|
977
|
+
be_dir = detected.get("backend_dir", actual_dir)
|
|
978
|
+
fe_dir = detected.get("frontend_dir", actual_dir)
|
|
979
|
+
be_cmd_str = detected.get("command", "")
|
|
980
|
+
fe_cmd_str = detected.get("frontend_command", "")
|
|
981
|
+
be_port = detected.get("backend_port", 8000)
|
|
982
|
+
fe_port = detected.get("expected_port", 3000)
|
|
983
|
+
|
|
984
|
+
# Install deps in both directories
|
|
985
|
+
for svc_dir_str in (be_dir, fe_dir):
|
|
986
|
+
svc_path = Path(svc_dir_str)
|
|
987
|
+
if (svc_path / "package.json").exists() and not (svc_path / "node_modules").exists():
|
|
988
|
+
try:
|
|
989
|
+
subprocess.run(["npm", "install"], cwd=svc_dir_str, capture_output=True, timeout=120, env=build_env)
|
|
990
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
991
|
+
logger.warning("npm install failed in %s: %s", svc_dir_str, exc)
|
|
992
|
+
if (svc_path / "requirements.txt").exists():
|
|
993
|
+
self._install_pip_deps(svc_path, build_env)
|
|
994
|
+
|
|
995
|
+
popen_kwargs = (
|
|
996
|
+
{"start_new_session": True} if sys.platform != "win32"
|
|
997
|
+
else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
# Start backend process (uses shell=True because command contains 'cd ...')
|
|
1001
|
+
try:
|
|
1002
|
+
be_proc = subprocess.Popen(
|
|
1003
|
+
be_cmd_str, shell=True,
|
|
1004
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
|
1005
|
+
text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
|
|
1006
|
+
)
|
|
1007
|
+
except Exception as e:
|
|
1008
|
+
return {"status": "error", "message": f"Failed to start backend: {e}"}
|
|
1009
|
+
_track_child_pid(be_proc.pid)
|
|
1010
|
+
|
|
1011
|
+
# Start frontend process
|
|
1012
|
+
try:
|
|
1013
|
+
fe_proc = subprocess.Popen(
|
|
1014
|
+
fe_cmd_str, shell=True,
|
|
1015
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
|
1016
|
+
text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
|
|
1017
|
+
)
|
|
1018
|
+
except Exception as e:
|
|
1019
|
+
# Kill backend if frontend fails to start
|
|
1020
|
+
try:
|
|
1021
|
+
be_proc.terminate()
|
|
1022
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1023
|
+
pass
|
|
1024
|
+
_untrack_child_pid(be_proc.pid)
|
|
1025
|
+
return {"status": "error", "message": f"Failed to start frontend: {e}"}
|
|
1026
|
+
_track_child_pid(fe_proc.pid)
|
|
1027
|
+
|
|
1028
|
+
server_info: dict = {
|
|
1029
|
+
"process": fe_proc, # Primary process is frontend (user-facing)
|
|
1030
|
+
"backend_process": be_proc,
|
|
1031
|
+
"port": None,
|
|
1032
|
+
"expected_port": fe_port,
|
|
1033
|
+
"backend_port": be_port,
|
|
1034
|
+
"command": fe_cmd_str,
|
|
1035
|
+
"original_command": cmd_str,
|
|
1036
|
+
"framework": framework,
|
|
1037
|
+
"status": "starting",
|
|
1038
|
+
"pid": fe_proc.pid,
|
|
1039
|
+
"backend_pid": be_proc.pid,
|
|
1040
|
+
"project_dir": project_dir,
|
|
1041
|
+
"output_lines": [],
|
|
1042
|
+
"backend_output_lines": [],
|
|
1043
|
+
"multi_service": True,
|
|
1044
|
+
"frontend_framework": detected.get("frontend_framework", "unknown"),
|
|
1045
|
+
"backend_framework": detected.get("backend_framework", "unknown"),
|
|
1046
|
+
"frontend_dir": fe_dir,
|
|
1047
|
+
"backend_dir": be_dir,
|
|
1048
|
+
"use_portless": False,
|
|
1049
|
+
"portless_app_name": None,
|
|
1050
|
+
}
|
|
1051
|
+
self.servers[session_id] = server_info
|
|
1052
|
+
|
|
1053
|
+
asyncio.create_task(self._monitor_output(session_id))
|
|
1054
|
+
asyncio.create_task(self._monitor_backend_output(session_id))
|
|
1055
|
+
|
|
1056
|
+
# Wait for either frontend or backend port (up to 30s)
|
|
1057
|
+
for _ in range(60):
|
|
1058
|
+
await asyncio.sleep(0.5)
|
|
1059
|
+
info = self.servers.get(session_id)
|
|
1060
|
+
if not info:
|
|
1061
|
+
return {"status": "error", "message": "Server entry disappeared"}
|
|
1062
|
+
if info["status"] == "error":
|
|
1063
|
+
return {
|
|
1064
|
+
"status": "error",
|
|
1065
|
+
"message": "Dev server crashed",
|
|
1066
|
+
"output": info["output_lines"][-10:] if info["output_lines"] else [],
|
|
1067
|
+
}
|
|
1068
|
+
if info["port"] is not None:
|
|
1069
|
+
health_ok = await self._health_check(info["port"])
|
|
1070
|
+
if health_ok:
|
|
1071
|
+
info["status"] = "running"
|
|
1072
|
+
services = [
|
|
1073
|
+
{
|
|
1074
|
+
"name": "frontend",
|
|
1075
|
+
"framework": info.get("frontend_framework", "unknown"),
|
|
1076
|
+
"port": fe_port,
|
|
1077
|
+
"status": "running",
|
|
1078
|
+
},
|
|
1079
|
+
{
|
|
1080
|
+
"name": "backend",
|
|
1081
|
+
"framework": info.get("backend_framework", "unknown"),
|
|
1082
|
+
"port": be_port,
|
|
1083
|
+
"status": "running" if be_proc.poll() is None else "error",
|
|
1084
|
+
},
|
|
1085
|
+
]
|
|
1086
|
+
return {
|
|
1087
|
+
"status": "running",
|
|
1088
|
+
"port": info["port"],
|
|
1089
|
+
"command": fe_cmd_str,
|
|
1090
|
+
"pid": fe_proc.pid,
|
|
1091
|
+
"url": f"/proxy/{session_id}/",
|
|
1092
|
+
"multi_service": True,
|
|
1093
|
+
"framework": "full-stack",
|
|
1094
|
+
"services": services,
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
# Timeout -- report whatever state we have
|
|
1098
|
+
if fe_proc.poll() is not None and be_proc.poll() is not None:
|
|
1099
|
+
server_info["status"] = "error"
|
|
1100
|
+
return {
|
|
1101
|
+
"status": "error",
|
|
1102
|
+
"message": "Both frontend and backend exited before port was detected",
|
|
1103
|
+
"output": server_info["output_lines"][-10:],
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
# Fallback to expected port
|
|
1107
|
+
health_ok = await self._health_check(fe_port)
|
|
1108
|
+
if health_ok:
|
|
1109
|
+
server_info["port"] = fe_port
|
|
1110
|
+
server_info["status"] = "running"
|
|
1111
|
+
return {
|
|
1112
|
+
"status": "running",
|
|
1113
|
+
"port": fe_port,
|
|
1114
|
+
"command": fe_cmd_str,
|
|
1115
|
+
"pid": fe_proc.pid,
|
|
1116
|
+
"url": f"/proxy/{session_id}/",
|
|
1117
|
+
"multi_service": True,
|
|
1118
|
+
"framework": "full-stack",
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
server_info["status"] = "starting"
|
|
1122
|
+
server_info["port"] = fe_port
|
|
1123
|
+
return {
|
|
1124
|
+
"status": "starting",
|
|
1125
|
+
"message": "Server started but port not yet confirmed",
|
|
1126
|
+
"port": fe_port,
|
|
1127
|
+
"command": fe_cmd_str,
|
|
1128
|
+
"pid": fe_proc.pid,
|
|
1129
|
+
"url": f"/proxy/{session_id}/",
|
|
1130
|
+
"multi_service": True,
|
|
1131
|
+
"framework": "full-stack",
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
# -- Single-service startup (original path) --
|
|
795
1135
|
|
|
796
1136
|
# Auto-install dependencies before starting the dev server
|
|
797
1137
|
actual_path = Path(actual_dir)
|
|
798
1138
|
needs_npm = (actual_path / "package.json").exists() and not (actual_path / "node_modules").exists()
|
|
799
1139
|
needs_pip = (actual_path / "requirements.txt").exists() and not (actual_path / "venv").exists()
|
|
800
1140
|
|
|
801
|
-
build_env = {**os.environ}
|
|
802
|
-
build_env.update(_load_secrets())
|
|
803
|
-
|
|
804
1141
|
if needs_npm:
|
|
805
1142
|
try:
|
|
806
1143
|
subprocess.run(
|
|
@@ -814,34 +1151,7 @@ class DevServerManager:
|
|
|
814
1151
|
logger.warning("npm install failed: %s", exc)
|
|
815
1152
|
|
|
816
1153
|
if needs_pip:
|
|
817
|
-
|
|
818
|
-
# installing into the server's own Python environment.
|
|
819
|
-
venv_dir = None
|
|
820
|
-
for venv_name in ("venv", ".venv", "env"):
|
|
821
|
-
candidate = actual_path / venv_name
|
|
822
|
-
if candidate.is_dir() and (candidate / "bin" / "pip").exists():
|
|
823
|
-
venv_dir = candidate
|
|
824
|
-
break
|
|
825
|
-
if venv_dir is None:
|
|
826
|
-
# Create a virtual environment for the project
|
|
827
|
-
try:
|
|
828
|
-
subprocess.run(
|
|
829
|
-
[sys.executable, "-m", "venv", str(actual_path / "venv")],
|
|
830
|
-
capture_output=True, timeout=60,
|
|
831
|
-
)
|
|
832
|
-
venv_dir = actual_path / "venv"
|
|
833
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
834
|
-
logger.warning("venv creation failed: %s", exc)
|
|
835
|
-
pip_executable = str(venv_dir / "bin" / "pip") if venv_dir else "pip"
|
|
836
|
-
try:
|
|
837
|
-
subprocess.run(
|
|
838
|
-
[pip_executable, "install", "-r", "requirements.txt"],
|
|
839
|
-
cwd=actual_dir,
|
|
840
|
-
capture_output=True,
|
|
841
|
-
timeout=120,
|
|
842
|
-
)
|
|
843
|
-
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
844
|
-
logger.warning("pip install failed: %s", exc)
|
|
1154
|
+
self._install_pip_deps(actual_path, build_env)
|
|
845
1155
|
|
|
846
1156
|
# Check if portless is available and proxy is running
|
|
847
1157
|
use_portless = False
|
|
@@ -875,7 +1185,7 @@ class DevServerManager:
|
|
|
875
1185
|
_track_child_pid(proc.pid)
|
|
876
1186
|
|
|
877
1187
|
effective_cmd = " ".join(cmd_parts)
|
|
878
|
-
server_info
|
|
1188
|
+
server_info = {
|
|
879
1189
|
"process": proc,
|
|
880
1190
|
"port": None,
|
|
881
1191
|
"expected_port": expected_port,
|
|
@@ -1016,6 +1326,39 @@ class DevServerManager:
|
|
|
1016
1326
|
except Exception:
|
|
1017
1327
|
logger.warning("Failed to schedule auto-fix for session %s", session_id, exc_info=True)
|
|
1018
1328
|
|
|
1329
|
+
async def _monitor_backend_output(self, session_id: str) -> None:
|
|
1330
|
+
"""Background task: read backend dev server stdout for multi-service setups."""
|
|
1331
|
+
info = self.servers.get(session_id)
|
|
1332
|
+
if not info or not info.get("multi_service"):
|
|
1333
|
+
return
|
|
1334
|
+
be_proc = info.get("backend_process")
|
|
1335
|
+
if not be_proc or not be_proc.stdout:
|
|
1336
|
+
return
|
|
1337
|
+
loop = asyncio.get_running_loop()
|
|
1338
|
+
try:
|
|
1339
|
+
while be_proc.poll() is None:
|
|
1340
|
+
line = await loop.run_in_executor(None, be_proc.stdout.readline)
|
|
1341
|
+
if not line:
|
|
1342
|
+
break
|
|
1343
|
+
text = line.rstrip("\n")
|
|
1344
|
+
be_lines = info.get("backend_output_lines", [])
|
|
1345
|
+
be_lines.append(text)
|
|
1346
|
+
if len(be_lines) > 200:
|
|
1347
|
+
be_lines = be_lines[-200:]
|
|
1348
|
+
info["backend_output_lines"] = be_lines
|
|
1349
|
+
# Also detect backend port from output
|
|
1350
|
+
detected_port = self._parse_port(text)
|
|
1351
|
+
if detected_port:
|
|
1352
|
+
info["backend_port"] = detected_port
|
|
1353
|
+
except Exception:
|
|
1354
|
+
logger.error("Backend monitor failed for session %s", session_id, exc_info=True)
|
|
1355
|
+
finally:
|
|
1356
|
+
if be_proc.poll() is not None and info.get("status") in ("starting", "running"):
|
|
1357
|
+
# Only mark error if frontend is also dead
|
|
1358
|
+
fe_proc = info.get("process")
|
|
1359
|
+
if fe_proc and fe_proc.poll() is not None:
|
|
1360
|
+
info["status"] = "error"
|
|
1361
|
+
|
|
1019
1362
|
async def _auto_fix(self, session_id: str, error_context: str) -> None:
|
|
1020
1363
|
"""Auto-fix a crashed dev server by invoking loki quick with the error."""
|
|
1021
1364
|
info = self.servers.get(session_id)
|
|
@@ -1169,6 +1512,34 @@ class DevServerManager:
|
|
|
1169
1512
|
pass
|
|
1170
1513
|
|
|
1171
1514
|
_untrack_child_pid(proc.pid)
|
|
1515
|
+
|
|
1516
|
+
# For multi-service setups, also kill the backend process
|
|
1517
|
+
be_proc = info.get("backend_process")
|
|
1518
|
+
if be_proc:
|
|
1519
|
+
if be_proc.poll() is None:
|
|
1520
|
+
if sys.platform != "win32":
|
|
1521
|
+
try:
|
|
1522
|
+
pgid = os.getpgid(be_proc.pid)
|
|
1523
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
1524
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1525
|
+
try:
|
|
1526
|
+
be_proc.terminate()
|
|
1527
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1528
|
+
pass
|
|
1529
|
+
else:
|
|
1530
|
+
try:
|
|
1531
|
+
be_proc.terminate()
|
|
1532
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1533
|
+
pass
|
|
1534
|
+
try:
|
|
1535
|
+
be_proc.wait(timeout=5)
|
|
1536
|
+
except subprocess.TimeoutExpired:
|
|
1537
|
+
try:
|
|
1538
|
+
be_proc.kill()
|
|
1539
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1540
|
+
pass
|
|
1541
|
+
_untrack_child_pid(be_proc.pid)
|
|
1542
|
+
|
|
1172
1543
|
return {"stopped": True, "message": "Dev server stopped"}
|
|
1173
1544
|
|
|
1174
1545
|
async def status(self, session_id: str) -> dict:
|
|
@@ -1203,6 +1574,29 @@ class DevServerManager:
|
|
|
1203
1574
|
"auto_fix_status": info.get("auto_fix_status"),
|
|
1204
1575
|
"auto_fix_attempts": info.get("auto_fix_attempts", 0),
|
|
1205
1576
|
}
|
|
1577
|
+
|
|
1578
|
+
# Multi-service status reporting
|
|
1579
|
+
if info.get("multi_service"):
|
|
1580
|
+
be_proc = info.get("backend_process")
|
|
1581
|
+
be_alive = be_proc.poll() is None if be_proc else False
|
|
1582
|
+
result["multi_service"] = True
|
|
1583
|
+
result["framework"] = "full-stack"
|
|
1584
|
+
result["services"] = [
|
|
1585
|
+
{
|
|
1586
|
+
"name": "frontend",
|
|
1587
|
+
"framework": info.get("frontend_framework", "unknown"),
|
|
1588
|
+
"port": info.get("expected_port"),
|
|
1589
|
+
"status": "running" if alive else "error",
|
|
1590
|
+
},
|
|
1591
|
+
{
|
|
1592
|
+
"name": "backend",
|
|
1593
|
+
"framework": info.get("backend_framework", "unknown"),
|
|
1594
|
+
"port": info.get("backend_port"),
|
|
1595
|
+
"status": "running" if be_alive else "error",
|
|
1596
|
+
},
|
|
1597
|
+
]
|
|
1598
|
+
result["backend_output"] = info.get("backend_output_lines", [])[-20:]
|
|
1599
|
+
|
|
1206
1600
|
if info.get("use_portless") and info.get("portless_app_name"):
|
|
1207
1601
|
app_name = info["portless_app_name"]
|
|
1208
1602
|
result["portless_url"] = f"http://{app_name}.localhost:1355/"
|