spec-kitty-cli 0.12.1__py3-none-any.whl
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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- specify_cli/workspace_context.py +224 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Dashboard lifecycle and health management utilities.
|
|
2
|
+
|
|
3
|
+
This module handles starting, stopping, and monitoring the dashboard server.
|
|
4
|
+
Uses psutil for cross-platform process management (Windows, Linux, macOS).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import socket
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional, Tuple
|
|
20
|
+
|
|
21
|
+
import psutil
|
|
22
|
+
|
|
23
|
+
from .server import find_free_port, start_dashboard
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ensure_dashboard_running",
|
|
29
|
+
"stop_dashboard",
|
|
30
|
+
"_parse_dashboard_file",
|
|
31
|
+
"_write_dashboard_file",
|
|
32
|
+
"_check_dashboard_health",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_dashboard_file(dashboard_file: Path) -> Tuple[Optional[str], Optional[int], Optional[str], Optional[int]]:
|
|
37
|
+
"""Read dashboard metadata from disk.
|
|
38
|
+
|
|
39
|
+
Format:
|
|
40
|
+
Line 1: URL (http://127.0.0.1:port)
|
|
41
|
+
Line 2: Port (integer)
|
|
42
|
+
Line 3: Token (optional)
|
|
43
|
+
Line 4: PID (optional, for process tracking)
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
content = dashboard_file.read_text(encoding='utf-8')
|
|
47
|
+
except Exception:
|
|
48
|
+
return None, None, None, None
|
|
49
|
+
|
|
50
|
+
lines = [line.strip() for line in content.splitlines() if line.strip()]
|
|
51
|
+
if not lines:
|
|
52
|
+
return None, None, None, None
|
|
53
|
+
|
|
54
|
+
url = lines[0] if lines else None
|
|
55
|
+
port = None
|
|
56
|
+
token = None
|
|
57
|
+
pid = None
|
|
58
|
+
|
|
59
|
+
if len(lines) >= 2:
|
|
60
|
+
try:
|
|
61
|
+
port = int(lines[1])
|
|
62
|
+
except ValueError:
|
|
63
|
+
port = None
|
|
64
|
+
|
|
65
|
+
if len(lines) >= 3:
|
|
66
|
+
token = lines[2] or None
|
|
67
|
+
|
|
68
|
+
if len(lines) >= 4:
|
|
69
|
+
try:
|
|
70
|
+
pid = int(lines[3])
|
|
71
|
+
except ValueError:
|
|
72
|
+
pid = None
|
|
73
|
+
|
|
74
|
+
return url, port, token, pid
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write_dashboard_file(
|
|
78
|
+
dashboard_file: Path,
|
|
79
|
+
url: str,
|
|
80
|
+
port: int,
|
|
81
|
+
token: Optional[str],
|
|
82
|
+
pid: Optional[int] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Persist dashboard metadata to disk.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
dashboard_file: Path to .dashboard metadata file
|
|
88
|
+
url: Dashboard URL (http://127.0.0.1:port)
|
|
89
|
+
port: Port number
|
|
90
|
+
token: Security token (optional)
|
|
91
|
+
pid: Process ID of background dashboard (optional)
|
|
92
|
+
"""
|
|
93
|
+
dashboard_file.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
lines = [url, str(port)]
|
|
95
|
+
if token:
|
|
96
|
+
lines.append(token)
|
|
97
|
+
if pid is not None:
|
|
98
|
+
lines.append(str(pid))
|
|
99
|
+
dashboard_file.write_text("\n".join(lines) + "\n", encoding='utf-8')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_process_alive(pid: int) -> bool:
|
|
103
|
+
"""Check if a process with the given PID is alive.
|
|
104
|
+
|
|
105
|
+
Uses psutil.Process() which works across all platforms (Linux, macOS, Windows).
|
|
106
|
+
This replaces the POSIX-only os.kill(pid, 0) approach.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
pid: Process ID to check
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if process exists and is running, False otherwise
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
proc = psutil.Process(pid)
|
|
116
|
+
return proc.is_running()
|
|
117
|
+
except psutil.NoSuchProcess:
|
|
118
|
+
# Process doesn't exist
|
|
119
|
+
return False
|
|
120
|
+
except psutil.AccessDenied:
|
|
121
|
+
# Process exists but we don't have permission to access it
|
|
122
|
+
# Consider this as "alive" since process exists
|
|
123
|
+
return True
|
|
124
|
+
except Exception:
|
|
125
|
+
# Unexpected error, assume process dead
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _is_spec_kitty_dashboard(port: int, timeout: float = 0.3) -> bool:
|
|
130
|
+
"""Check if the process on the given port is a spec-kitty dashboard.
|
|
131
|
+
|
|
132
|
+
Uses health check endpoint fingerprinting to safely identify spec-kitty dashboards.
|
|
133
|
+
Only returns True if we can confirm it's a spec-kitty dashboard.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
port: Port number to check
|
|
137
|
+
timeout: Health check timeout in seconds
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if confirmed to be a spec-kitty dashboard, False otherwise
|
|
141
|
+
"""
|
|
142
|
+
health_url = f"http://127.0.0.1:{port}/api/health"
|
|
143
|
+
try:
|
|
144
|
+
with urllib.request.urlopen(health_url, timeout=timeout) as response:
|
|
145
|
+
if response.status != 200:
|
|
146
|
+
return False
|
|
147
|
+
payload = response.read()
|
|
148
|
+
except Exception:
|
|
149
|
+
# Can't reach or parse - not a spec-kitty dashboard (or dead)
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(payload.decode('utf-8'))
|
|
154
|
+
# Verify this is actually a spec-kitty dashboard by checking for expected fields
|
|
155
|
+
return 'project_path' in data and 'status' in data
|
|
156
|
+
except Exception:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cleanup_orphaned_dashboards_in_range(start_port: int = 9237, port_count: int = 100) -> int:
|
|
161
|
+
"""Clean up orphaned spec-kitty dashboard processes in the port range.
|
|
162
|
+
|
|
163
|
+
This function safely identifies spec-kitty dashboard processes via health check
|
|
164
|
+
fingerprinting and kills only confirmed spec-kitty processes. This handles orphans
|
|
165
|
+
that have no .dashboard file (e.g., from failed startups or deleted temp projects).
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
start_port: Starting port number (default: 9237)
|
|
169
|
+
port_count: Number of ports to check (default: 100)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Number of orphaned processes killed
|
|
173
|
+
"""
|
|
174
|
+
import subprocess
|
|
175
|
+
|
|
176
|
+
killed_count = 0
|
|
177
|
+
|
|
178
|
+
for port in range(start_port, start_port + port_count):
|
|
179
|
+
# Check if port is occupied
|
|
180
|
+
try:
|
|
181
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
182
|
+
sock.settimeout(0.1)
|
|
183
|
+
if sock.connect_ex(('127.0.0.1', port)) != 0:
|
|
184
|
+
# Port is free, skip
|
|
185
|
+
continue
|
|
186
|
+
except Exception:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# Port is occupied - check if it's a spec-kitty dashboard
|
|
190
|
+
if _is_spec_kitty_dashboard(port):
|
|
191
|
+
# It's a spec-kitty dashboard - try to find and kill the process
|
|
192
|
+
try:
|
|
193
|
+
# Use lsof to find PID listening on this port
|
|
194
|
+
result = subprocess.run(
|
|
195
|
+
['lsof', '-ti', f':{port}', '-sTCP:LISTEN'],
|
|
196
|
+
capture_output=True,
|
|
197
|
+
text=True,
|
|
198
|
+
timeout=2,
|
|
199
|
+
)
|
|
200
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
201
|
+
pids = [int(pid) for pid in result.stdout.strip().split('\n') if pid.strip()]
|
|
202
|
+
for pid in pids:
|
|
203
|
+
try:
|
|
204
|
+
proc = psutil.Process(pid)
|
|
205
|
+
proc.kill()
|
|
206
|
+
killed_count += 1
|
|
207
|
+
except psutil.NoSuchProcess:
|
|
208
|
+
pass # Already dead
|
|
209
|
+
except psutil.AccessDenied:
|
|
210
|
+
pass # Can't kill (permissions)
|
|
211
|
+
except Exception:
|
|
212
|
+
# Can't use lsof or failed to kill - skip this port
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
return killed_count
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _check_dashboard_health(
|
|
219
|
+
port: int,
|
|
220
|
+
project_dir: Path,
|
|
221
|
+
expected_token: Optional[str],
|
|
222
|
+
timeout: float = 0.5,
|
|
223
|
+
) -> bool:
|
|
224
|
+
"""Verify that the dashboard on the port belongs to the provided project."""
|
|
225
|
+
health_url = f"http://127.0.0.1:{port}/api/health"
|
|
226
|
+
try:
|
|
227
|
+
with urllib.request.urlopen(health_url, timeout=timeout) as response:
|
|
228
|
+
if response.status != 200:
|
|
229
|
+
return False
|
|
230
|
+
payload = response.read()
|
|
231
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, ConnectionError, socket.error):
|
|
232
|
+
return False
|
|
233
|
+
except Exception:
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
data = json.loads(payload.decode('utf-8'))
|
|
238
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
remote_path = data.get('project_path')
|
|
242
|
+
if not remote_path:
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
remote_resolved = str(Path(remote_path).resolve())
|
|
247
|
+
except Exception:
|
|
248
|
+
remote_resolved = str(remote_path)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
expected_path = str(project_dir.resolve())
|
|
252
|
+
except Exception:
|
|
253
|
+
expected_path = str(project_dir)
|
|
254
|
+
|
|
255
|
+
if remote_resolved != expected_path:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
remote_token = data.get('token')
|
|
259
|
+
if expected_token:
|
|
260
|
+
return remote_token == expected_token
|
|
261
|
+
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def ensure_dashboard_running(
|
|
266
|
+
project_dir: Path,
|
|
267
|
+
preferred_port: Optional[int] = None,
|
|
268
|
+
background_process: bool = True,
|
|
269
|
+
) -> Tuple[str, int, bool]:
|
|
270
|
+
"""
|
|
271
|
+
Ensure a dashboard server is running for the provided project directory.
|
|
272
|
+
|
|
273
|
+
This function:
|
|
274
|
+
1. Checks if a dashboard is already running (health check)
|
|
275
|
+
2. Cleans up this project's orphaned process if stored PID is dead
|
|
276
|
+
3. If starting new dashboard fails due to port exhaustion, cleans up orphaned
|
|
277
|
+
spec-kitty dashboards across the entire port range and retries
|
|
278
|
+
4. Starts a new dashboard if needed
|
|
279
|
+
5. Stores the PID for future cleanup
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Tuple of (url, port, started) where started is True when a new server was launched.
|
|
283
|
+
"""
|
|
284
|
+
project_dir_resolved = project_dir.resolve()
|
|
285
|
+
dashboard_file = project_dir_resolved / '.kittify' / '.dashboard'
|
|
286
|
+
|
|
287
|
+
existing_url = None
|
|
288
|
+
existing_port = None
|
|
289
|
+
existing_token = None
|
|
290
|
+
existing_pid = None
|
|
291
|
+
|
|
292
|
+
# STEP 1: Check if we have a stale .dashboard file from a dead process
|
|
293
|
+
if dashboard_file.exists():
|
|
294
|
+
existing_url, existing_port, existing_token, existing_pid = _parse_dashboard_file(dashboard_file)
|
|
295
|
+
|
|
296
|
+
# First, try health check - if dashboard is healthy, reuse it
|
|
297
|
+
if existing_port is not None and _check_dashboard_health(existing_port, project_dir_resolved, existing_token):
|
|
298
|
+
url = existing_url or f"http://127.0.0.1:{existing_port}"
|
|
299
|
+
return url, existing_port, False
|
|
300
|
+
|
|
301
|
+
# Dashboard not responding - clean up orphaned process if we have a PID
|
|
302
|
+
if existing_pid is not None and not _is_process_alive(existing_pid):
|
|
303
|
+
# Process is dead, clean up the metadata file
|
|
304
|
+
dashboard_file.unlink(missing_ok=True)
|
|
305
|
+
elif existing_pid is not None and existing_port is not None:
|
|
306
|
+
# PID is alive but port not responding - kill the orphan
|
|
307
|
+
try:
|
|
308
|
+
proc = psutil.Process(existing_pid)
|
|
309
|
+
proc.kill()
|
|
310
|
+
dashboard_file.unlink(missing_ok=True)
|
|
311
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
312
|
+
# Already dead or can't kill - just clean up metadata
|
|
313
|
+
dashboard_file.unlink(missing_ok=True)
|
|
314
|
+
else:
|
|
315
|
+
# No PID recorded - just clean up metadata file
|
|
316
|
+
dashboard_file.unlink(missing_ok=True)
|
|
317
|
+
|
|
318
|
+
# STEP 2: Try to start a new dashboard
|
|
319
|
+
if preferred_port is not None:
|
|
320
|
+
try:
|
|
321
|
+
port_to_use = find_free_port(preferred_port, max_attempts=1)
|
|
322
|
+
except RuntimeError:
|
|
323
|
+
port_to_use = None
|
|
324
|
+
else:
|
|
325
|
+
port_to_use = None
|
|
326
|
+
|
|
327
|
+
token = secrets.token_hex(16)
|
|
328
|
+
|
|
329
|
+
# Try starting dashboard - if it fails due to port exhaustion, cleanup and retry
|
|
330
|
+
try:
|
|
331
|
+
port, pid = start_dashboard(
|
|
332
|
+
project_dir_resolved,
|
|
333
|
+
port=port_to_use,
|
|
334
|
+
background_process=background_process,
|
|
335
|
+
project_token=token,
|
|
336
|
+
)
|
|
337
|
+
except RuntimeError as e:
|
|
338
|
+
# If port exhaustion, try cleaning up orphaned dashboards and retry once
|
|
339
|
+
if "Could not find free port" in str(e):
|
|
340
|
+
killed = _cleanup_orphaned_dashboards_in_range()
|
|
341
|
+
if killed > 0:
|
|
342
|
+
# Cleanup succeeded, retry starting dashboard
|
|
343
|
+
port, pid = start_dashboard(
|
|
344
|
+
project_dir_resolved,
|
|
345
|
+
port=port_to_use,
|
|
346
|
+
background_process=background_process,
|
|
347
|
+
project_token=token,
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
# No orphans found or couldn't clean up - re-raise original error
|
|
351
|
+
raise
|
|
352
|
+
else:
|
|
353
|
+
# Different error - re-raise
|
|
354
|
+
raise
|
|
355
|
+
|
|
356
|
+
url = f"http://127.0.0.1:{port}"
|
|
357
|
+
|
|
358
|
+
# Wait for dashboard to become healthy (20 seconds with exponential backoff)
|
|
359
|
+
# Start with quick checks, then slow down for slower systems
|
|
360
|
+
retry_delays = [0.1] * 10 + [0.25] * 40 + [0.5] * 20 # ~20 seconds total
|
|
361
|
+
for delay in retry_delays:
|
|
362
|
+
if _check_dashboard_health(port, project_dir_resolved, token):
|
|
363
|
+
_write_dashboard_file(dashboard_file, url, port, token, pid)
|
|
364
|
+
return url, port, True
|
|
365
|
+
time.sleep(delay)
|
|
366
|
+
|
|
367
|
+
# Dashboard started but never became healthy
|
|
368
|
+
# Check if port has an orphaned dashboard from a different project
|
|
369
|
+
if _is_spec_kitty_dashboard(port):
|
|
370
|
+
# Port has a spec-kitty dashboard but for wrong project - orphan detected
|
|
371
|
+
# Clean up the failed process we just started
|
|
372
|
+
if pid is not None:
|
|
373
|
+
try:
|
|
374
|
+
proc = psutil.Process(pid)
|
|
375
|
+
proc.kill()
|
|
376
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
# Cleanup orphaned dashboards and retry ONCE
|
|
380
|
+
killed = _cleanup_orphaned_dashboards_in_range()
|
|
381
|
+
if killed > 0:
|
|
382
|
+
# Retry starting dashboard after cleanup
|
|
383
|
+
token = secrets.token_hex(16)
|
|
384
|
+
port, pid = start_dashboard(
|
|
385
|
+
project_dir_resolved,
|
|
386
|
+
port=port_to_use,
|
|
387
|
+
background_process=background_process,
|
|
388
|
+
project_token=token,
|
|
389
|
+
)
|
|
390
|
+
url = f"http://127.0.0.1:{port}"
|
|
391
|
+
|
|
392
|
+
# Wait for health check again
|
|
393
|
+
for delay in retry_delays:
|
|
394
|
+
if _check_dashboard_health(port, project_dir_resolved, token):
|
|
395
|
+
_write_dashboard_file(dashboard_file, url, port, token, pid)
|
|
396
|
+
return url, port, True
|
|
397
|
+
time.sleep(delay)
|
|
398
|
+
|
|
399
|
+
# Still failed - clean up and raise error
|
|
400
|
+
if pid is not None:
|
|
401
|
+
try:
|
|
402
|
+
proc = psutil.Process(pid)
|
|
403
|
+
proc.kill()
|
|
404
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
raise RuntimeError(f"Dashboard failed to start on port {port} for project {project_dir_resolved}")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def stop_dashboard(project_dir: Path, timeout: float = 5.0) -> Tuple[bool, str]:
|
|
411
|
+
"""
|
|
412
|
+
Attempt to stop the dashboard server for the provided project directory.
|
|
413
|
+
|
|
414
|
+
Tries graceful HTTP shutdown first, then falls back to killing by PID if needed.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Tuple[bool, str]: (stopped, message)
|
|
418
|
+
"""
|
|
419
|
+
project_dir_resolved = project_dir.resolve()
|
|
420
|
+
dashboard_file = project_dir_resolved / '.kittify' / '.dashboard'
|
|
421
|
+
|
|
422
|
+
if not dashboard_file.exists():
|
|
423
|
+
return False, "No dashboard metadata found."
|
|
424
|
+
|
|
425
|
+
_, port, token, pid = _parse_dashboard_file(dashboard_file)
|
|
426
|
+
if port is None:
|
|
427
|
+
dashboard_file.unlink(missing_ok=True)
|
|
428
|
+
return False, "Dashboard metadata was invalid and has been cleared."
|
|
429
|
+
|
|
430
|
+
if not _check_dashboard_health(port, project_dir_resolved, token):
|
|
431
|
+
dashboard_file.unlink(missing_ok=True)
|
|
432
|
+
return False, "Dashboard was already stopped. Metadata has been cleared."
|
|
433
|
+
|
|
434
|
+
shutdown_url = f"http://127.0.0.1:{port}/api/shutdown"
|
|
435
|
+
|
|
436
|
+
def _attempt_get() -> Tuple[bool, Optional[str]]:
|
|
437
|
+
params = {}
|
|
438
|
+
if token:
|
|
439
|
+
params['token'] = token
|
|
440
|
+
query = urllib.parse.urlencode(params)
|
|
441
|
+
request_url = f"{shutdown_url}?{query}" if query else shutdown_url
|
|
442
|
+
try:
|
|
443
|
+
urllib.request.urlopen(request_url, timeout=1)
|
|
444
|
+
return True, None
|
|
445
|
+
except urllib.error.HTTPError as exc:
|
|
446
|
+
if exc.code == 403:
|
|
447
|
+
return False, "Dashboard refused shutdown (token mismatch)."
|
|
448
|
+
if exc.code in (404, 405, 501):
|
|
449
|
+
return False, None
|
|
450
|
+
return False, f"Dashboard shutdown failed with HTTP {exc.code}."
|
|
451
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError, socket.error) as exc:
|
|
452
|
+
return False, f"Dashboard shutdown request failed: {exc}"
|
|
453
|
+
except Exception as exc:
|
|
454
|
+
return False, f"Unexpected error during shutdown: {exc}"
|
|
455
|
+
|
|
456
|
+
def _attempt_post() -> Tuple[bool, Optional[str]]:
|
|
457
|
+
payload = json.dumps({'token': token}).encode('utf-8')
|
|
458
|
+
request = urllib.request.Request(
|
|
459
|
+
shutdown_url,
|
|
460
|
+
data=payload,
|
|
461
|
+
headers={'Content-Type': 'application/json'},
|
|
462
|
+
method='POST',
|
|
463
|
+
)
|
|
464
|
+
try:
|
|
465
|
+
urllib.request.urlopen(request, timeout=1)
|
|
466
|
+
return True, None
|
|
467
|
+
except urllib.error.HTTPError as exc:
|
|
468
|
+
if exc.code == 403:
|
|
469
|
+
return False, "Dashboard refused shutdown (token mismatch)."
|
|
470
|
+
if exc.code == 501:
|
|
471
|
+
return False, "Dashboard does not support remote shutdown (upgrade required)."
|
|
472
|
+
return False, f"Dashboard shutdown failed with HTTP {exc.code}."
|
|
473
|
+
except (urllib.error.URLError, TimeoutError, ConnectionError, socket.error) as exc:
|
|
474
|
+
return False, f"Dashboard shutdown request failed: {exc}"
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
return False, f"Unexpected error during shutdown: {exc}"
|
|
477
|
+
|
|
478
|
+
# Try graceful HTTP shutdown first
|
|
479
|
+
ok, error_message = _attempt_get()
|
|
480
|
+
if not ok and error_message is None:
|
|
481
|
+
ok, error_message = _attempt_post()
|
|
482
|
+
|
|
483
|
+
# If HTTP shutdown failed but we have a PID, try killing the process
|
|
484
|
+
if not ok and pid is not None:
|
|
485
|
+
try:
|
|
486
|
+
proc = psutil.Process(pid)
|
|
487
|
+
|
|
488
|
+
# Try graceful termination first (SIGTERM on POSIX, equivalent on Windows)
|
|
489
|
+
proc.terminate()
|
|
490
|
+
|
|
491
|
+
# Wait up to 3 seconds for graceful shutdown
|
|
492
|
+
try:
|
|
493
|
+
proc.wait(timeout=3.0)
|
|
494
|
+
# Process exited gracefully
|
|
495
|
+
dashboard_file.unlink(missing_ok=True)
|
|
496
|
+
return True, f"Dashboard stopped via process termination (PID {pid})."
|
|
497
|
+
except psutil.TimeoutExpired:
|
|
498
|
+
# Timeout expired, process still running, force kill
|
|
499
|
+
proc.kill()
|
|
500
|
+
time.sleep(0.2)
|
|
501
|
+
dashboard_file.unlink(missing_ok=True)
|
|
502
|
+
return True, f"Dashboard force killed after graceful termination timeout (PID {pid})."
|
|
503
|
+
|
|
504
|
+
except psutil.NoSuchProcess:
|
|
505
|
+
# Process already dead (common race condition)
|
|
506
|
+
dashboard_file.unlink(missing_ok=True)
|
|
507
|
+
return True, f"Dashboard was already dead (PID {pid})."
|
|
508
|
+
except psutil.AccessDenied:
|
|
509
|
+
# Can't access process (permissions issue)
|
|
510
|
+
return False, f"Permission denied to kill dashboard process (PID {pid})."
|
|
511
|
+
except Exception as e:
|
|
512
|
+
# Unexpected error
|
|
513
|
+
logger.error(f"Unexpected error stopping dashboard process {pid}: {e}")
|
|
514
|
+
return False, f"Failed to kill dashboard process (PID {pid}): {e}"
|
|
515
|
+
|
|
516
|
+
if not ok:
|
|
517
|
+
return False, error_message or "Dashboard shutdown failed."
|
|
518
|
+
|
|
519
|
+
# Wait for graceful shutdown to complete
|
|
520
|
+
deadline = time.monotonic() + timeout
|
|
521
|
+
while time.monotonic() < deadline:
|
|
522
|
+
if not _check_dashboard_health(port, project_dir_resolved, token):
|
|
523
|
+
dashboard_file.unlink(missing_ok=True)
|
|
524
|
+
return True, f"Dashboard stopped and metadata cleared (port {port})."
|
|
525
|
+
time.sleep(0.1)
|
|
526
|
+
|
|
527
|
+
# Timeout - try killing by PID as last resort
|
|
528
|
+
if pid is not None:
|
|
529
|
+
try:
|
|
530
|
+
proc = psutil.Process(pid)
|
|
531
|
+
proc.kill()
|
|
532
|
+
dashboard_file.unlink(missing_ok=True)
|
|
533
|
+
return True, f"Dashboard forced stopped (force kill, PID {pid}) after {timeout}s timeout."
|
|
534
|
+
except psutil.NoSuchProcess:
|
|
535
|
+
# Process died between health check and kill attempt
|
|
536
|
+
dashboard_file.unlink(missing_ok=True)
|
|
537
|
+
return True, f"Dashboard process ended (PID {pid})."
|
|
538
|
+
except Exception as e:
|
|
539
|
+
logger.error(f"Failed to force kill dashboard process {pid}: {e}")
|
|
540
|
+
|
|
541
|
+
return False, f"Dashboard did not stop within {timeout} seconds."
|