@josephyan/qingflow-cli 0.2.0-beta.72 → 0.2.0-beta.74

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.
@@ -0,0 +1,533 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from typing import Any
6
+
7
+ from ..config import (
8
+ get_repository_develop_branch,
9
+ get_repository_generate_default_agent_id,
10
+ get_repository_generate_default_route_prefix,
11
+ get_repository_generate_default_token_name,
12
+ get_repository_internal_base_url,
13
+ get_repository_internal_share_token,
14
+ get_repository_internal_share_token_key,
15
+ get_repository_preview_address_template,
16
+ get_repository_prod_branch,
17
+ )
18
+ from ..errors import QingflowApiError, raise_tool_error
19
+ from ..json_types import JSONObject, JSONValue
20
+ from ..repository_store import RepositoryMetadataStore
21
+ from ..session_store import SessionProfile
22
+ from .base import ToolBase
23
+
24
+
25
+ _STREAM_EVENT_RE = re.compile(r"^type=(?P<type>[^,]+),timestamp=(?P<timestamp>[^,]+),data=(?P<data>.*)$")
26
+ _STREAM_NEWLINE_TOKEN = "<wingsBr>"
27
+
28
+
29
+ class RepositoryDevTools(ToolBase):
30
+ def __init__(self, sessions, backend, *, metadata_store: RepositoryMetadataStore | None = None) -> None:
31
+ super().__init__(sessions, backend)
32
+ self._metadata = metadata_store or RepositoryMetadataStore()
33
+
34
+ def repository_init(self, *, profile: str, group_name: str, repo_template: str) -> JSONObject:
35
+ normalized_group = str(group_name or "").strip()
36
+ normalized_template = str(repo_template or "").strip()
37
+ if not normalized_group:
38
+ raise_tool_error(QingflowApiError.config_error("group_name is required"))
39
+ if not normalized_template:
40
+ raise_tool_error(QingflowApiError.config_error("repo_template is required"))
41
+
42
+ def runner(session_profile: SessionProfile, context):
43
+ payload = self._request_custom_page_json(
44
+ session_profile,
45
+ context,
46
+ "POST",
47
+ "/ultron/custom_page/v1/_init",
48
+ params={"groupName": normalized_group, "repoTemplate": normalized_template},
49
+ )
50
+ repo_name = _extract_repo_name(payload)
51
+ preview_address = _format_preview_address(repo_name)
52
+ stored = self._metadata.put(
53
+ repo_name,
54
+ {
55
+ "group_name": normalized_group,
56
+ "repo_template": normalized_template,
57
+ "preview_address": preview_address,
58
+ },
59
+ )
60
+ return {
61
+ "status": "success",
62
+ "repo_name": repo_name,
63
+ "group_name": stored.get("group_name"),
64
+ "repo_template": stored.get("repo_template"),
65
+ "preview_address": preview_address,
66
+ "verification": {"repo_initialized": True},
67
+ "warnings": self._custom_page_route_warning(),
68
+ }
69
+
70
+ return self._run(profile, runner, require_workspace=True)
71
+
72
+ def repository_generate(
73
+ self,
74
+ *,
75
+ profile: str,
76
+ repo_name: str,
77
+ query: str,
78
+ tag_id: int | None = None,
79
+ app_keys: list[str] | None = None,
80
+ extra_info: JSONObject | None = None,
81
+ file_messages: list[JSONObject] | None = None,
82
+ being_trace_log_enabled: bool = True,
83
+ agent_id: int | None = None,
84
+ allow_create_table: bool = False,
85
+ route_prefix: str | None = None,
86
+ token_name: str | None = None,
87
+ session_id: str | None = None,
88
+ round_version: int | None = None,
89
+ ) -> JSONObject:
90
+ normalized_repo = str(repo_name or "").strip()
91
+ normalized_query = str(query or "").strip()
92
+ if not normalized_repo:
93
+ raise_tool_error(QingflowApiError.config_error("repo_name is required"))
94
+ if not normalized_query:
95
+ raise_tool_error(QingflowApiError.config_error("query is required"))
96
+
97
+ metadata = self._metadata.get(normalized_repo) or {}
98
+ normalized_tag_id = _normalize_optional_int(tag_id, "tag_id") or _normalize_optional_int(metadata.get("tag_id"), "tag_id")
99
+ normalized_app_keys = _normalize_string_list(app_keys or metadata.get("app_keys") or [], field_name="app_keys")
100
+ normalized_extra_info = _normalize_optional_object(extra_info, field_name="extra_info")
101
+ normalized_file_messages = _normalize_optional_list_of_objects(file_messages, field_name="file_messages")
102
+ normalized_agent_id = _normalize_optional_int(agent_id, "agent_id") or get_repository_generate_default_agent_id()
103
+ normalized_route_prefix = _normalize_optional_string(route_prefix) or _normalize_optional_string(metadata.get("route_prefix")) or get_repository_generate_default_route_prefix()
104
+ normalized_token_name = _normalize_optional_string(token_name) or _normalize_optional_string(metadata.get("token_name")) or get_repository_generate_default_token_name()
105
+ normalized_session_id = _normalize_optional_string(session_id)
106
+ normalized_round_version = _normalize_optional_int(round_version, "round_version")
107
+
108
+ def runner(session_profile: SessionProfile, context):
109
+ payload: JSONObject = {
110
+ "query": normalized_query,
111
+ "repoName": normalized_repo,
112
+ "uid": session_profile.uid,
113
+ "beingTraceLogEnabled": bool(being_trace_log_enabled),
114
+ "allowCreateTable": bool(allow_create_table),
115
+ }
116
+ if normalized_tag_id is not None:
117
+ payload["tagId"] = normalized_tag_id
118
+ if normalized_app_keys:
119
+ payload["appKeys"] = normalized_app_keys
120
+ if normalized_extra_info is not None:
121
+ payload["extraInfo"] = normalized_extra_info
122
+ if normalized_file_messages:
123
+ payload["fileMessages"] = normalized_file_messages
124
+ if normalized_agent_id is not None:
125
+ payload["agentId"] = normalized_agent_id
126
+ if normalized_route_prefix is not None:
127
+ payload["routePrefix"] = normalized_route_prefix
128
+ if normalized_token_name is not None:
129
+ payload["tokenName"] = normalized_token_name
130
+ if normalized_session_id is not None:
131
+ payload["sessionId"] = normalized_session_id
132
+ if normalized_round_version is not None:
133
+ payload["roundVersion"] = normalized_round_version
134
+
135
+ stream_lines = self._request_custom_page_stream(
136
+ session_profile,
137
+ context,
138
+ "POST",
139
+ "/ultron/custom_page/v1/_generate",
140
+ json_body=payload,
141
+ )
142
+ summarized = _summarize_generate_stream(
143
+ repo_name=normalized_repo,
144
+ query=normalized_query,
145
+ stream_lines=stream_lines,
146
+ route_warning=self._custom_page_route_warning(),
147
+ )
148
+ self._metadata.put(
149
+ normalized_repo,
150
+ {
151
+ "tag_id": normalized_tag_id,
152
+ "app_keys": normalized_app_keys,
153
+ "route_prefix": normalized_route_prefix,
154
+ "token_name": normalized_token_name,
155
+ "agent_id": normalized_agent_id,
156
+ "last_generate_query": normalized_query,
157
+ "last_generate_session_id": summarized.get("session_id"),
158
+ "last_generate_round_version": summarized.get("round_version"),
159
+ },
160
+ )
161
+ return summarized
162
+
163
+ return self._run(profile, runner, require_workspace=True)
164
+
165
+ def repository_publish_prod(
166
+ self,
167
+ *,
168
+ profile: str,
169
+ repo_name: str,
170
+ confirm: bool = False,
171
+ ) -> JSONObject:
172
+ normalized_repo = str(repo_name or "").strip()
173
+ if not normalized_repo:
174
+ raise_tool_error(QingflowApiError.config_error("repo_name is required"))
175
+ if confirm is not True:
176
+ raise_tool_error(QingflowApiError.config_error("confirm=true is required for repository_publish_prod"))
177
+
178
+ def runner(session_profile: SessionProfile, context):
179
+ self._request_custom_page_json(
180
+ session_profile,
181
+ context,
182
+ "POST",
183
+ "/ultron/custom_page/v1/_publish",
184
+ params={"repoName": normalized_repo},
185
+ )
186
+ preview_address = _lookup_preview_address(normalized_repo, self._metadata)
187
+ return {
188
+ "status": "success",
189
+ "repo_name": normalized_repo,
190
+ "source_branch": get_repository_develop_branch(),
191
+ "target_branch": get_repository_prod_branch(),
192
+ "merge_status": "success",
193
+ "pipeline_status": "success",
194
+ "published": True,
195
+ "preview_address": preview_address,
196
+ "verification": {"publish_confirmed": True, "pipeline_verified": True},
197
+ "warnings": self._custom_page_route_warning(),
198
+ }
199
+
200
+ return self._run(profile, runner, require_workspace=True)
201
+
202
+ def _custom_page_route_warning(self) -> list[JSONObject]:
203
+ if get_repository_internal_base_url() and get_repository_internal_share_token():
204
+ return []
205
+ return [
206
+ {
207
+ "code": "CUSTOM_PAGE_ROUTE_PUBLIC_FALLBACK",
208
+ "message": (
209
+ "repository tools are using the current Qingflow session route. "
210
+ "If the official custom-page internal route is not exposed in this environment, "
211
+ "configure repository.internal_base_url and repository.internal_share_token."
212
+ ),
213
+ }
214
+ ]
215
+
216
+ def _request_custom_page_json(
217
+ self,
218
+ session_profile: SessionProfile,
219
+ context,
220
+ method: str,
221
+ path: str,
222
+ *,
223
+ params: JSONObject | None = None,
224
+ json_body: JSONValue = None,
225
+ ) -> JSONValue:
226
+ internal_route = _resolve_internal_custom_page_route(session_profile)
227
+ if internal_route is not None:
228
+ return self.backend.public_request_with_headers(
229
+ method,
230
+ internal_route["base_url"],
231
+ path,
232
+ params=params,
233
+ json_body=json_body,
234
+ headers=internal_route["headers"],
235
+ qf_version=context.qf_version,
236
+ ).data
237
+ return self.backend.request(method, context, path, params=params, json_body=json_body)
238
+
239
+ def _request_custom_page_stream(
240
+ self,
241
+ session_profile: SessionProfile,
242
+ context,
243
+ method: str,
244
+ path: str,
245
+ *,
246
+ params: JSONObject | None = None,
247
+ json_body: JSONValue = None,
248
+ ) -> list[str]:
249
+ internal_route = _resolve_internal_custom_page_route(session_profile)
250
+ if internal_route is not None:
251
+ return self.backend.public_stream_request(
252
+ method,
253
+ internal_route["base_url"],
254
+ path,
255
+ params=params,
256
+ json_body=json_body,
257
+ headers=internal_route["headers"],
258
+ qf_version=context.qf_version,
259
+ )
260
+ return self.backend.stream_request(method, context, path, params=params, json_body=json_body)
261
+
262
+
263
+ def _extract_repo_name(payload: Any) -> str:
264
+ if isinstance(payload, str) and payload.strip():
265
+ return payload.strip()
266
+ if isinstance(payload, dict):
267
+ for key in ("repoName", "repo_name"):
268
+ value = payload.get(key)
269
+ if isinstance(value, str) and value.strip():
270
+ return value.strip()
271
+ raise_tool_error(QingflowApiError(category="runtime", message="repository init did not return repo_name"))
272
+ raise AssertionError("unreachable")
273
+
274
+
275
+ def _format_preview_address(repo_name: str) -> str:
276
+ template = get_repository_preview_address_template()
277
+ short_name = repo_name.split("/")[-1]
278
+ if "%s" in template:
279
+ return template % short_name
280
+ return template.format(repo=short_name)
281
+
282
+
283
+ def _lookup_preview_address(repo_name: str, store: RepositoryMetadataStore) -> str | None:
284
+ normalized = repo_name.strip()
285
+ short_name = normalized.split("/")[-1]
286
+ stored = store.get(short_name) or {}
287
+ preview_address = stored.get("preview_address")
288
+ return preview_address if isinstance(preview_address, str) and preview_address.strip() else None
289
+
290
+
291
+ def _resolve_internal_custom_page_route(session_profile: SessionProfile) -> dict[str, Any] | None:
292
+ base_url = get_repository_internal_base_url()
293
+ share_token = get_repository_internal_share_token()
294
+ token_key = get_repository_internal_share_token_key()
295
+ if not base_url and not share_token:
296
+ return None
297
+ if not base_url or not share_token:
298
+ raise_tool_error(
299
+ QingflowApiError.config_error(
300
+ "repository.internal_base_url and repository.internal_share_token must be configured together"
301
+ )
302
+ )
303
+ if session_profile.selected_ws_id is None:
304
+ raise_tool_error(
305
+ QingflowApiError.config_error("workspace_select is required before using the internal custom-page route")
306
+ )
307
+ return {
308
+ "base_url": base_url,
309
+ "headers": {
310
+ token_key: share_token,
311
+ "wsId": str(session_profile.selected_ws_id),
312
+ },
313
+ }
314
+
315
+
316
+ def _normalize_optional_string(value: Any) -> str | None:
317
+ normalized = str(value or "").strip()
318
+ return normalized or None
319
+
320
+
321
+ def _normalize_optional_int(value: Any, field_name: str) -> int | None:
322
+ if value is None or value == "":
323
+ return None
324
+ try:
325
+ return int(value)
326
+ except (TypeError, ValueError):
327
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be an integer"))
328
+ raise AssertionError("unreachable")
329
+
330
+
331
+ def _normalize_string_list(values: list[Any], *, field_name: str) -> list[str]:
332
+ normalized: list[str] = []
333
+ for item in values:
334
+ text = str(item or "").strip()
335
+ if not text:
336
+ continue
337
+ normalized.append(text)
338
+ return normalized
339
+
340
+
341
+ def _normalize_optional_object(value: Any, *, field_name: str) -> JSONObject | None:
342
+ if value is None:
343
+ return None
344
+ if not isinstance(value, dict):
345
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be an object"))
346
+ return value
347
+
348
+
349
+ def _normalize_optional_list_of_objects(value: Any, *, field_name: str) -> list[JSONObject]:
350
+ if value is None:
351
+ return []
352
+ if not isinstance(value, list):
353
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be a list"))
354
+ normalized: list[JSONObject] = []
355
+ for item in value:
356
+ if not isinstance(item, dict):
357
+ raise_tool_error(QingflowApiError.config_error(f"each {field_name} item must be an object"))
358
+ normalized.append(item)
359
+ return normalized
360
+
361
+
362
+ def _parse_stream_line(raw_line: str) -> JSONObject | None:
363
+ line = str(raw_line or "").strip()
364
+ if not line:
365
+ return None
366
+ if not line.startswith("data: "):
367
+ return {"type": "raw", "data": line}
368
+ body = line[6:]
369
+ if body == "[done]":
370
+ return {"type": "done", "data": "[done]"}
371
+ match = _STREAM_EVENT_RE.match(body)
372
+ if not match:
373
+ return {"type": "raw", "data": body}
374
+ event_type = match.group("type")
375
+ timestamp_raw = str(match.group("timestamp") or "").strip()
376
+ try:
377
+ timestamp = int(timestamp_raw) if timestamp_raw else None
378
+ except ValueError:
379
+ timestamp = None
380
+ data_text = match.group("data").replace(_STREAM_NEWLINE_TOKEN, "\n")
381
+ data = _maybe_parse_json(data_text)
382
+ return {
383
+ "type": event_type,
384
+ "timestamp": timestamp,
385
+ "data": data,
386
+ }
387
+
388
+
389
+ def _maybe_parse_json(value: str) -> JSONValue:
390
+ text = str(value or "").strip()
391
+ if not text:
392
+ return ""
393
+ try:
394
+ return json.loads(text)
395
+ except json.JSONDecodeError:
396
+ return text
397
+
398
+
399
+ def _summarize_generate_stream(
400
+ *,
401
+ repo_name: str,
402
+ query: str,
403
+ stream_lines: list[str],
404
+ route_warning: list[JSONObject],
405
+ ) -> JSONObject:
406
+ parsed_events = [event for event in (_parse_stream_line(line) for line in stream_lines) if event is not None]
407
+ done_seen = any(str(event.get("type")) == "done" for event in parsed_events)
408
+ result_event = next((event for event in reversed(parsed_events) if event.get("type") == "result"), None)
409
+ error_event = next((event for event in reversed(parsed_events) if event.get("type") == "error"), None)
410
+ repository_events = [
411
+ event.get("data")
412
+ for event in parsed_events
413
+ if event.get("type") == "event"
414
+ and isinstance(event.get("data"), dict)
415
+ and event["data"].get("action") == "REPOSITORY_COMMIT"
416
+ ]
417
+ other_events = [
418
+ event.get("data")
419
+ for event in parsed_events
420
+ if event.get("type") == "event"
421
+ and not (
422
+ isinstance(event.get("data"), dict)
423
+ and event["data"].get("action") == "REPOSITORY_COMMIT"
424
+ )
425
+ ]
426
+ token_count = sum(1 for event in parsed_events if event.get("type") == "token")
427
+ running_count = sum(1 for event in parsed_events if event.get("type") == "runing")
428
+ waiting_count = sum(1 for event in parsed_events if event.get("type") == "waiting")
429
+ raw_count = sum(1 for event in parsed_events if event.get("type") == "raw")
430
+ warnings = list(route_warning)
431
+ if raw_count:
432
+ warnings.append(
433
+ {
434
+ "code": "GENERATE_STREAM_UNPARSED_CHUNKS",
435
+ "message": f"generate stream contained {raw_count} unparsed chunk(s); raw chunks are returned for debugging",
436
+ }
437
+ )
438
+ if not done_seen:
439
+ warnings.append(
440
+ {
441
+ "code": "GENERATE_STREAM_DONE_MISSING",
442
+ "message": "generate stream ended without an explicit [done] marker",
443
+ }
444
+ )
445
+
446
+ if error_event is not None:
447
+ error_data = error_event.get("data")
448
+ error_code: str | None = None
449
+ message = "repository generation failed"
450
+ if isinstance(error_data, dict):
451
+ error_code = str(error_data.get("errorCode") or error_data.get("code") or "").strip() or None
452
+ message = str(error_data.get("errorMessage") or error_data.get("message") or message)
453
+ elif isinstance(error_data, str) and error_data.strip():
454
+ message = error_data.strip()
455
+ return {
456
+ "status": "failed",
457
+ "error_code": error_code or "REPOSITORY_GENERATE_FAILED",
458
+ "message": message,
459
+ "repo_name": repo_name,
460
+ "query": query,
461
+ "repository_events": repository_events,
462
+ "events": other_events,
463
+ "stream_summary": {
464
+ "token_events": token_count,
465
+ "running_events": running_count,
466
+ "waiting_events": waiting_count,
467
+ "done_seen": done_seen,
468
+ "raw_chunks": raw_count,
469
+ },
470
+ "verification": {
471
+ "stream_done": done_seen,
472
+ "result_received": False,
473
+ "repository_commit_detected": bool(repository_events),
474
+ },
475
+ "warnings": warnings,
476
+ }
477
+
478
+ result_payload = result_event.get("data") if isinstance(result_event, dict) else None
479
+ if not isinstance(result_payload, dict):
480
+ return {
481
+ "status": "failed",
482
+ "error_code": "REPOSITORY_GENERATE_EMPTY_RESULT",
483
+ "message": "repository generation stream completed without a structured result payload",
484
+ "repo_name": repo_name,
485
+ "query": query,
486
+ "repository_events": repository_events,
487
+ "events": other_events,
488
+ "stream_summary": {
489
+ "token_events": token_count,
490
+ "running_events": running_count,
491
+ "waiting_events": waiting_count,
492
+ "done_seen": done_seen,
493
+ "raw_chunks": raw_count,
494
+ },
495
+ "verification": {
496
+ "stream_done": done_seen,
497
+ "result_received": False,
498
+ "repository_commit_detected": bool(repository_events),
499
+ },
500
+ "warnings": warnings,
501
+ }
502
+
503
+ response_status = str(result_payload.get("responseStatus") or "SUCCESS")
504
+ return {
505
+ "status": "success" if response_status.upper() == "SUCCESS" else "failed",
506
+ "repo_name": repo_name,
507
+ "query": query,
508
+ "response_status": response_status,
509
+ "session_id": result_payload.get("sessionId"),
510
+ "round_version": result_payload.get("roundVersion"),
511
+ "thread_id": result_payload.get("threadId"),
512
+ "answer": result_payload.get("answer"),
513
+ "answer_json": result_payload.get("answerJson"),
514
+ "trace_logs": result_payload.get("traceLogs"),
515
+ "total_tokens": result_payload.get("totalTokens"),
516
+ "credit_consume": result_payload.get("creditConsume"),
517
+ "history_messages": result_payload.get("historyMessages"),
518
+ "repository_events": repository_events,
519
+ "events": other_events,
520
+ "stream_summary": {
521
+ "token_events": token_count,
522
+ "running_events": running_count,
523
+ "waiting_events": waiting_count,
524
+ "done_seen": done_seen,
525
+ "raw_chunks": raw_count,
526
+ },
527
+ "verification": {
528
+ "stream_done": done_seen,
529
+ "result_received": True,
530
+ "repository_commit_detected": bool(repository_events),
531
+ },
532
+ "warnings": warnings,
533
+ }