qalita 2.3.1__py3-none-any.whl → 2.5.2__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.
Files changed (95) hide show
  1. qalita/__main__.py +213 -9
  2. qalita/commands/{agent.py → worker.py} +89 -89
  3. qalita/internal/config.py +26 -19
  4. qalita/internal/utils.py +1 -1
  5. qalita/web/app.py +97 -14
  6. qalita/web/blueprints/context.py +13 -60
  7. qalita/web/blueprints/dashboard.py +35 -76
  8. qalita/web/blueprints/helpers.py +154 -63
  9. qalita/web/blueprints/sources.py +29 -61
  10. qalita/web/blueprints/{agents.py → workers.py} +108 -185
  11. qalita-2.5.2.dist-info/METADATA +66 -0
  12. qalita-2.5.2.dist-info/RECORD +24 -0
  13. {qalita-2.3.1.dist-info → qalita-2.5.2.dist-info}/WHEEL +1 -1
  14. qalita-2.5.2.dist-info/entry_points.txt +2 -0
  15. qalita/web/blueprints/studio.py +0 -1255
  16. qalita/web/public/chatgpt.svg +0 -3
  17. qalita/web/public/claude.png +0 -0
  18. qalita/web/public/favicon.ico +0 -0
  19. qalita/web/public/gemini.png +0 -0
  20. qalita/web/public/logo-no-slogan.png +0 -0
  21. qalita/web/public/logo-white-no-slogan.svg +0 -11
  22. qalita/web/public/mistral.svg +0 -1
  23. qalita/web/public/noise.webp +0 -0
  24. qalita/web/public/ollama.png +0 -0
  25. qalita/web/public/platform.png +0 -0
  26. qalita/web/public/sources-logos/alloy-db.png +0 -0
  27. qalita/web/public/sources-logos/amazon-athena.png +0 -0
  28. qalita/web/public/sources-logos/amazon-rds.png +0 -0
  29. qalita/web/public/sources-logos/api.svg +0 -2
  30. qalita/web/public/sources-logos/avro.svg +0 -20
  31. qalita/web/public/sources-logos/azure-database-mysql.png +0 -0
  32. qalita/web/public/sources-logos/azure-database-postgresql.png +0 -0
  33. qalita/web/public/sources-logos/azure-sql-database.png +0 -0
  34. qalita/web/public/sources-logos/azure-sql-managed-instance.png +0 -0
  35. qalita/web/public/sources-logos/azure-synapse-analytics.png +0 -0
  36. qalita/web/public/sources-logos/azure_blob.svg +0 -1
  37. qalita/web/public/sources-logos/bigquery.png +0 -0
  38. qalita/web/public/sources-logos/cassandra.svg +0 -254
  39. qalita/web/public/sources-logos/clickhouse.png +0 -0
  40. qalita/web/public/sources-logos/cloud-sql.png +0 -0
  41. qalita/web/public/sources-logos/cockroach-db.png +0 -0
  42. qalita/web/public/sources-logos/csv.svg +0 -1
  43. qalita/web/public/sources-logos/database.svg +0 -3
  44. qalita/web/public/sources-logos/databricks.png +0 -0
  45. qalita/web/public/sources-logos/duckdb.png +0 -0
  46. qalita/web/public/sources-logos/elasticsearch.svg +0 -1
  47. qalita/web/public/sources-logos/excel.svg +0 -1
  48. qalita/web/public/sources-logos/file.svg +0 -1
  49. qalita/web/public/sources-logos/folder.svg +0 -6
  50. qalita/web/public/sources-logos/gcs.png +0 -0
  51. qalita/web/public/sources-logos/hdfs.svg +0 -1
  52. qalita/web/public/sources-logos/ibm-db2.png +0 -0
  53. qalita/web/public/sources-logos/json.png +0 -0
  54. qalita/web/public/sources-logos/maria-db.png +0 -0
  55. qalita/web/public/sources-logos/mongodb.svg +0 -1
  56. qalita/web/public/sources-logos/mssql.svg +0 -1
  57. qalita/web/public/sources-logos/mysql.svg +0 -7
  58. qalita/web/public/sources-logos/oracle.svg +0 -4
  59. qalita/web/public/sources-logos/parquet.svg +0 -16
  60. qalita/web/public/sources-logos/picture.png +0 -0
  61. qalita/web/public/sources-logos/postgresql.svg +0 -22
  62. qalita/web/public/sources-logos/questdb.png +0 -0
  63. qalita/web/public/sources-logos/redshift.png +0 -0
  64. qalita/web/public/sources-logos/s3.svg +0 -34
  65. qalita/web/public/sources-logos/sap-hana.png +0 -0
  66. qalita/web/public/sources-logos/sftp.png +0 -0
  67. qalita/web/public/sources-logos/single-store.png +0 -0
  68. qalita/web/public/sources-logos/snowflake.png +0 -0
  69. qalita/web/public/sources-logos/sqlite.svg +0 -104
  70. qalita/web/public/sources-logos/sqlserver.png +0 -0
  71. qalita/web/public/sources-logos/starburst.png +0 -0
  72. qalita/web/public/sources-logos/stream.png +0 -0
  73. qalita/web/public/sources-logos/teradata.png +0 -0
  74. qalita/web/public/sources-logos/timescale.png +0 -0
  75. qalita/web/public/sources-logos/xls.svg +0 -1
  76. qalita/web/public/sources-logos/xlsx.svg +0 -1
  77. qalita/web/public/sources-logos/yugabyte-db.png +0 -0
  78. qalita/web/public/studio-logo.svg +0 -10
  79. qalita/web/public/studio.css +0 -304
  80. qalita/web/public/studio.png +0 -0
  81. qalita/web/public/styles.css +0 -682
  82. qalita/web/templates/dashboard.html +0 -373
  83. qalita/web/templates/navbar.html +0 -40
  84. qalita/web/templates/sources/added.html +0 -57
  85. qalita/web/templates/sources/edit.html +0 -411
  86. qalita/web/templates/sources/select-source.html +0 -128
  87. qalita/web/templates/studio/agent-panel.html +0 -769
  88. qalita/web/templates/studio/context-panel.html +0 -300
  89. qalita/web/templates/studio/index.html +0 -79
  90. qalita/web/templates/studio/navbar.html +0 -14
  91. qalita/web/templates/studio/view-panel.html +0 -529
  92. qalita-2.3.1.dist-info/METADATA +0 -58
  93. qalita-2.3.1.dist-info/RECORD +0 -101
  94. qalita-2.3.1.dist-info/entry_points.txt +0 -3
  95. {qalita-2.3.1.dist-info → qalita-2.5.2.dist-info}/licenses/LICENSE +0 -0
qalita/web/app.py CHANGED
@@ -3,38 +3,121 @@
3
3
  """
4
4
 
5
5
  import os
6
- from flask import Flask
6
+ from flask import Flask, request
7
+ from flask_socketio import SocketIO
7
8
  from waitress import serve
8
9
 
10
+ # Global SocketIO instance
11
+ socketio = None
12
+
9
13
  def create_app(config_obj) -> Flask:
10
- """Application factory for the QALITA CLI UI."""
11
- app = Flask(
12
- __name__,
13
- static_folder=os.path.join(os.path.dirname(__file__), "public"),
14
- static_url_path="/static",
15
- template_folder=os.path.join(os.path.dirname(__file__), "templates"),
16
- )
14
+ """Application factory for the QALITA CLI UI - API only."""
15
+ global socketio
17
16
 
17
+ app = Flask(__name__)
18
18
  app.config["QALITA_CONFIG_OBJ"] = config_obj
19
+ app.config["SECRET_KEY"] = os.urandom(24)
20
+
21
+ # Initialize SocketIO with CORS enabled
22
+ # Try gevent first, fall back to threading if gevent causes issues (e.g., Python 3.13 compatibility)
23
+ async_mode = "threading" # Default to threading for better compatibility
24
+ try:
25
+ # Try to use gevent if available and working
26
+ import sys
27
+ if sys.version_info < (3, 13):
28
+ # Only try gevent on Python < 3.13 due to zope.interface compatibility issues
29
+ async_mode = "gevent"
30
+ except Exception:
31
+ pass
19
32
 
20
- # Register blueprints
33
+ socketio = SocketIO(
34
+ app,
35
+ cors_allowed_origins="*",
36
+ async_mode=async_mode,
37
+ logger=False,
38
+ engineio_logger=False,
39
+ )
40
+
41
+ # Register blueprints (API only, no templates)
21
42
  from qalita.web.blueprints.dashboard import bp as dashboard_bp
22
43
  from qalita.web.blueprints.context import bp as context_bp
23
- from qalita.web.blueprints.agents import bp as agents_bp
24
44
  from qalita.web.blueprints.sources import bp as sources_bp
25
- from qalita.web.blueprints.studio import bp as studio_bp
45
+ from qalita.web.blueprints.workers import bp as workers_bp
26
46
 
27
47
  app.register_blueprint(dashboard_bp)
28
48
  app.register_blueprint(context_bp)
29
- app.register_blueprint(agents_bp)
30
49
  app.register_blueprint(sources_bp, url_prefix="/sources")
31
- app.register_blueprint(studio_bp, url_prefix="/studio")
50
+ app.register_blueprint(workers_bp) # No prefix - routes already include /worker/ and /agent/
51
+
52
+ # Try to import qalita-studio if installed (optional dependency)
53
+ try:
54
+ from qalita_studio.api.blueprints.studio import bp as studio_bp
55
+ from qalita_studio.api.blueprints.studio import register_studio_socketio_handlers
56
+ app.register_blueprint(studio_bp, url_prefix="/studio")
57
+ register_studio_socketio_handlers()
58
+ except ImportError:
59
+ pass # qalita-studio not installed
60
+
61
+ # CORS headers for Next.js frontend
62
+ # IMPORTANT: Socket.IO requests must be completely bypassed from Flask middleware
63
+ # as they don't follow standard WSGI flow and can cause "write() before start_response" errors
64
+ @app.after_request
65
+ def after_request(response):
66
+ # Skip Socket.IO requests - they are handled by Socket.IO middleware and don't follow standard WSGI flow
67
+ # We need to detect Socket.IO requests very early and return immediately to avoid WSGI protocol violations
68
+ try:
69
+ # Check WSGI environment PATH_INFO directly - this is the most reliable method
70
+ # and works even if Flask's request context is in an invalid state
71
+ environ = None
72
+ if hasattr(request, 'environ'):
73
+ environ = request.environ
74
+ elif hasattr(request, '_get_current_object'):
75
+ # Try to get the underlying request object
76
+ try:
77
+ req_obj = request._get_current_object()
78
+ if hasattr(req_obj, 'environ'):
79
+ environ = req_obj.environ
80
+ except (AttributeError, RuntimeError):
81
+ pass
82
+
83
+ if environ:
84
+ path_info = environ.get('PATH_INFO', '')
85
+ # If this is a Socket.IO request, return immediately without any processing
86
+ if path_info and path_info.startswith('/socket.io/'):
87
+ return response
88
+ except (AttributeError, RuntimeError, KeyError, TypeError):
89
+ # If we can't safely check, return response as-is to prevent WSGI violations
90
+ return response
91
+
92
+ # Only add CORS headers for non-Socket.IO requests
93
+ # Also check that response object is valid before modifying
94
+ try:
95
+ if response and hasattr(response, 'headers'):
96
+ response.headers.add('Access-Control-Allow-Origin', '*')
97
+ response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
98
+ response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
99
+ except (AttributeError, RuntimeError):
100
+ # If response object is invalid, just return it as-is
101
+ pass
102
+
103
+ return response
32
104
 
33
105
  return app
34
106
 
35
107
 
108
+ def get_socketio():
109
+ """Get the global SocketIO instance."""
110
+ return socketio
111
+
112
+
36
113
  def run_dashboard_ui(config_obj, host: str = "localhost", port: int = 7070):
37
114
  app = create_app(config_obj)
115
+ socketio = get_socketio()
38
116
  url = f"http://{host}:{port}"
39
117
  print(f"QALITA CLI UI is running. Open {url}")
40
- serve(app, host=host, port=port)
118
+ # Use SocketIO's built-in server for WebSocket support
119
+ if socketio:
120
+ socketio.run(app, host=host, port=port, use_reloader=False, log_output=False, allow_unsafe_werkzeug=True)
121
+ else:
122
+ from waitress import serve
123
+ serve(app, host=host, port=port)
@@ -4,6 +4,7 @@
4
4
 
5
5
  from flask import Blueprint, jsonify, current_app, request
6
6
  import requests
7
+ import os
7
8
 
8
9
  from .helpers import (
9
10
  list_env_files,
@@ -11,11 +12,12 @@ from .helpers import (
11
12
  selected_env_file_path,
12
13
  qalita_home,
13
14
  parse_env_file,
15
+ write_selected_env_atomic,
14
16
  )
15
17
  from qalita.internal.utils import logger
16
18
  from qalita.internal.request import send_request
17
19
  from qalita.internal.utils import get_version
18
- from qalita.commands.agent import authenticate, send_alive
20
+ from qalita.commands.worker import authenticate, send_alive
19
21
 
20
22
 
21
23
  bp = Blueprint("context", __name__)
@@ -27,8 +29,6 @@ def list_contexts():
27
29
  selected = read_selected_env()
28
30
  try:
29
31
  if selected:
30
- import os
31
-
32
32
  sel_norm = os.path.normcase(os.path.normpath(selected))
33
33
  for it in files:
34
34
  if os.path.normcase(os.path.normpath(it.get("path", ""))) == sel_norm:
@@ -41,8 +41,6 @@ def list_contexts():
41
41
 
42
42
  @bp.post("/context/select")
43
43
  def select_context():
44
- import os
45
-
46
44
  data = request.get_json(silent=True) or {}
47
45
  path = (data.get("path") or "").strip()
48
46
 
@@ -51,8 +49,11 @@ def select_context():
51
49
  if not path:
52
50
  try:
53
51
  p = selected_env_file_path()
54
- if os.path.exists(p):
55
- os.remove(p)
52
+ # Use lock when removing to avoid race conditions
53
+ from .helpers import _current_env_lock
54
+ with _current_env_lock:
55
+ if os.path.exists(p):
56
+ os.remove(p)
56
57
  ok = True
57
58
  message = "Selection cleared"
58
59
  except Exception as exc:
@@ -69,8 +70,11 @@ def select_context():
69
70
  if not os.path.isfile(abs_path):
70
71
  logger.warning("Context env file not found on disk")
71
72
  return jsonify({"ok": False, "message": "Env file not found"}), 404
72
- with open(selected_env_file_path(), "w", encoding="utf-8") as f:
73
- f.write(abs_path)
73
+
74
+ # Use atomic write with locking
75
+ p = selected_env_file_path()
76
+ if not write_selected_env_atomic(p, abs_path):
77
+ return jsonify({"ok": False, "message": "Failed to write context selection"}), 500
74
78
 
75
79
  # Apply selected context: login and persist
76
80
  try:
@@ -145,54 +149,3 @@ def _login_with_env(env_path: str) -> None:
145
149
  pass
146
150
  except Exception:
147
151
  pass
148
-
149
-
150
- @bp.get("/context/issues")
151
- def list_user_issues():
152
- """Proxy list of issues for the current user using selected context env.
153
-
154
- Reads the selected env file (or current app config if already set) to get URL and TOKEN,
155
- then calls backend /api/v2/issues and returns items.
156
- """
157
- cfg = current_app.config["QALITA_CONFIG_OBJ"]
158
- url = getattr(cfg, "url", None)
159
- token = getattr(cfg, "token", None)
160
- try:
161
- if not url or not token:
162
- # Fallback to selected env file
163
- sel_path = read_selected_env()
164
- if sel_path:
165
- data = parse_env_file(sel_path)
166
- url = (
167
- data.get("QALITA_AGENT_ENDPOINT")
168
- or data.get("QALITA_URL")
169
- or data.get("URL")
170
- or url
171
- )
172
- token = data.get("QALITA_AGENT_TOKEN") or data.get("QALITA_TOKEN") or data.get("TOKEN") or token
173
- except Exception:
174
- pass
175
- if not url or not token:
176
- return jsonify({"ok": False, "message": "Missing platform URL or TOKEN in context"}), 400
177
- try:
178
- base = str(url).rstrip("/")
179
- endpoint = base + "/api/v2/issues"
180
- headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
181
- r = requests.get(endpoint, headers=headers, timeout=10)
182
- if 200 <= r.status_code < 300:
183
- try:
184
- body = r.json()
185
- except Exception:
186
- body = []
187
- # Normalize items: some APIs return { items: [...] }, others a list directly
188
- items = body.get("items") if isinstance(body, dict) else body
189
- if not isinstance(items, list):
190
- items = []
191
- return jsonify({"ok": True, "items": items})
192
- try:
193
- err = r.json()
194
- except Exception:
195
- err = {"detail": r.text[:200]}
196
- return jsonify({"ok": False, "status": r.status_code, "error": err}), 200
197
- except Exception as exc:
198
- return jsonify({"ok": False, "message": str(exc)}), 200
@@ -2,7 +2,7 @@
2
2
  # QALITA (c) COPYRIGHT 2025 - ALL RIGHTS RESERVED -
3
3
  """
4
4
 
5
- from flask import Blueprint, current_app, render_template, request
5
+ from flask import Blueprint, current_app, request, jsonify
6
6
 
7
7
  from qalita.internal.request import send_request
8
8
  from .helpers import (
@@ -17,6 +17,20 @@ bp = Blueprint("dashboard", __name__)
17
17
 
18
18
  @bp.route("/")
19
19
  def dashboard():
20
+ """Legacy route - redirects to Next.js frontend"""
21
+ # Next.js will handle the frontend
22
+ # This route is kept for backward compatibility but should not be used
23
+ return _get_dashboard_data_json()
24
+
25
+
26
+ @bp.route("/api/dashboard")
27
+ def dashboard_api():
28
+ """API endpoint for dashboard data"""
29
+ return _get_dashboard_data_json()
30
+
31
+
32
+ def _get_dashboard_data_json():
33
+ """Get dashboard data as JSON"""
20
34
  cfg = current_app.config["QALITA_CONFIG_OBJ"]
21
35
  agent_conf, agent_runs = compute_agent_summary(cfg)
22
36
  # Load local sources regardless of agent configuration
@@ -81,80 +95,25 @@ def dashboard():
81
95
  runs_start = (start_idx + 1) if total_runs > 0 and start_idx < total_runs else 0
82
96
  runs_end = min(end_idx, total_runs) if total_runs > 0 else 0
83
97
 
84
- return render_template(
85
- "dashboard.html",
86
- agent_conf=agent_conf or {},
87
- sources=sources,
88
- agent_runs=agent_runs,
89
- agent_runs_page=agent_runs_page,
90
- runs_total=total_runs,
91
- runs_page=page,
92
- runs_per_page=per_page,
93
- runs_has_prev=runs_has_prev,
94
- runs_has_next=runs_has_next,
95
- runs_start=runs_start,
96
- runs_end=runs_end,
97
- platform_url=platform_url,
98
- )
98
+ return jsonify({
99
+ "agent_conf": agent_conf or {},
100
+ "sources": sources,
101
+ "agent_runs": agent_runs,
102
+ "agent_runs_page": agent_runs_page,
103
+ "runs_total": total_runs,
104
+ "runs_page": page,
105
+ "runs_per_page": per_page,
106
+ "runs_has_prev": runs_has_prev,
107
+ "runs_has_next": runs_has_next,
108
+ "runs_start": runs_start,
109
+ "runs_end": runs_end,
110
+ "platform_url": platform_url,
111
+ })
99
112
 
100
113
 
101
114
  def dashboard_with_feedback(feedback_msg=None, feedback_level: str = "info"):
102
- cfg = current_app.config["QALITA_CONFIG_OBJ"]
103
- agent_conf, agent_runs = compute_agent_summary(cfg)
104
- # Load local sources for display
105
- try:
106
- cfg.load_source_config()
107
- sources = list(reversed(cfg.config.get("sources", [])))
108
- except Exception:
109
- sources = []
110
- # Resolve platform URL as above, best effort
111
- platform_url = None
112
- try:
113
- backend_url = getattr(cfg, "url", None)
114
- # Try override from selected .env context to ensure consistency after POST actions
115
- try:
116
- env_path = read_selected_env()
117
- if env_path:
118
- data = parse_env_file(env_path)
119
- backend_url = (
120
- data.get("QALITA_AGENT_ENDPOINT")
121
- or data.get("QALITA_URL")
122
- or data.get("URL")
123
- or backend_url
124
- )
125
- except Exception:
126
- pass
127
- if backend_url:
128
- try:
129
- r = send_request.__wrapped__(
130
- cfg, request=f"{backend_url}/api/v1/info", mode="get"
131
- ) # type: ignore[attr-defined]
132
- except Exception:
133
- r = None
134
- if r is not None and getattr(r, "status_code", None) == 200:
135
- try:
136
- platform_url = (r.json() or {}).get("public_platform_url")
137
- except Exception:
138
- platform_url = None
139
- except Exception:
140
- platform_url = None
141
- return render_template(
142
- "dashboard.html",
143
- agent_conf=agent_conf or {},
144
- sources=sources,
145
- agent_runs=agent_runs,
146
- agent_runs_page=agent_runs[:10],
147
- runs_total=len(agent_runs),
148
- runs_page=1,
149
- runs_per_page=10,
150
- runs_has_prev=False,
151
- runs_has_next=len(agent_runs) > 10,
152
- runs_start=1 if agent_runs else 0,
153
- runs_end=min(10, len(agent_runs)) if agent_runs else 0,
154
- feedback=feedback_msg,
155
- feedback_level=feedback_level,
156
- platform_url=platform_url,
157
- )
115
+ """Legacy function - kept for backward compatibility"""
116
+ return _get_dashboard_data_json()
158
117
 
159
118
 
160
119
  @bp.post("/validate")
@@ -188,7 +147,7 @@ def validate_sources():
188
147
  except Exception:
189
148
  msg = "Validation completed."
190
149
  level = "info"
191
- return dashboard_with_feedback(msg, level)
150
+ return jsonify({"ok": True, "message": msg, "level": level})
192
151
 
193
152
 
194
153
  @bp.post("/push")
@@ -202,7 +161,7 @@ def push_sources():
202
161
  except Exception as exc:
203
162
  ok, message = False, f"Push failed: {exc}"
204
163
  level = "info" if ok else "error"
205
- return dashboard_with_feedback(message, level)
164
+ return jsonify({"ok": ok, "message": message, "level": level})
206
165
 
207
166
 
208
167
  @bp.post("/pack/push")
@@ -222,5 +181,5 @@ def push_pack_from_ui():
222
181
  else:
223
182
  feedback = "Please select a pack folder."
224
183
  feedback_level = "error"
225
- # Refresh dashboard with feedback
226
- return dashboard_with_feedback(feedback, feedback_level)
184
+ # Return JSON response
185
+ return jsonify({"ok": feedback_level == "info", "message": feedback, "level": feedback_level})