synapse-hub 2.0.0 → 2.1.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.
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ tools/bump_version.py — Atomic Universal Version Bumper for Synapse Hub
5
+
6
+ Synchronizes version numbers across all polyglot targets in a single command:
7
+ 1. package.json (NPM)
8
+ 2. pyproject.toml (Python/Poetry/PEP 621)
9
+ 3. skills_manifest.json (Skill Catalog Index)
10
+ 4. tools/cli.py (Agent-QA CLI)
11
+ 5. tools/mcp_server.py (MCP Protocol Server)
12
+ 6. tools/browser_helpers.js (In-Browser CDP Helper)
13
+ 7. tools/mcp_excel_server.ps1 (PowerShell MCP Service)
14
+ 8. docs/ARCHITECTURE.md (Architecture Specifications)
15
+ 9. README.md (Bilingual Documentation & Badges)
16
+
17
+ Usage:
18
+ python3 tools/bump_version.py patch # 2.0.1 -> 2.0.2
19
+ python3 tools/bump_version.py minor # 2.0.1 -> 2.1.0
20
+ python3 tools/bump_version.py major # 2.0.1 -> 3.0.0
21
+ python3 tools/bump_version.py 2.0.5 # Explicit version
22
+ python3 tools/bump_version.py patch --commit # Bump + git commit
23
+ python3 tools/bump_version.py patch --commit --push # Bump + commit + git push + tag
24
+ python3 tools/bump_version.py patch --dry-run # Preview only
25
+ """
26
+
27
+ import sys
28
+ import os
29
+ import re
30
+ import json
31
+ import argparse
32
+ import subprocess
33
+
34
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
35
+
36
+ def parse_semver(v_str: str):
37
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)(.*)$", v_str.strip())
38
+ if not m:
39
+ raise ValueError(f"Invalid semver: '{v_str}'. Must follow major.minor.patch[-suffix]")
40
+ return int(m.group(1)), int(m.group(2)), int(m.group(3)), m.group(4)
41
+
42
+ def calculate_next_version(current: str, action: str) -> str:
43
+ major, minor, patch, suffix = parse_semver(current)
44
+ action = action.lower()
45
+ if action == "patch":
46
+ return f"{major}.{minor}.{patch + 1}"
47
+ elif action == "minor":
48
+ return f"{major}.{minor + 1}.0"
49
+ elif action == "major":
50
+ return f"{major + 1}.0.0"
51
+ else:
52
+ # Validate that action itself is a valid semver
53
+ parse_semver(action)
54
+ return action
55
+
56
+ def get_current_version() -> str:
57
+ pkg_file = os.path.join(REPO_ROOT, "package.json")
58
+ with open(pkg_file, "r", encoding="utf-8") as f:
59
+ data = json.load(f)
60
+ return data.get("version", "2.0.0")
61
+
62
+ def update_json_root_version(rel_path: str, new_ver: str, dry_run: bool = False) -> bool:
63
+ full_path = os.path.join(REPO_ROOT, rel_path)
64
+ if not os.path.exists(full_path):
65
+ print(f" [WARN] File not found: {rel_path}")
66
+ return False
67
+
68
+ with open(full_path, "r", encoding="utf-8") as f:
69
+ data = json.load(f)
70
+
71
+ old_ver = data.get("version")
72
+ if old_ver == new_ver:
73
+ print(f" [SKIP] {rel_path} already at v{new_ver}")
74
+ return False
75
+
76
+ data["version"] = new_ver
77
+ if not dry_run:
78
+ with open(full_path, "w", encoding="utf-8", newline="\n") as f:
79
+ json.dump(data, f, indent=2, ensure_ascii=False)
80
+ f.write("\n")
81
+ print(f" [UPDATED] {rel_path} (root version {old_ver} -> {new_ver})")
82
+ else:
83
+ print(f" [DRY-RUN] Would update {rel_path} (root version {old_ver} -> {new_ver})")
84
+ return True
85
+
86
+ def update_file(rel_path: str, pattern: str, replacement: str, dry_run: bool = False) -> bool:
87
+ full_path = os.path.join(REPO_ROOT, rel_path)
88
+ if not os.path.exists(full_path):
89
+ print(f" [WARN] File not found: {rel_path}")
90
+ return False
91
+
92
+ with open(full_path, "r", encoding="utf-8") as f:
93
+ content = f.read()
94
+
95
+ new_content, count = re.subn(pattern, replacement, content)
96
+ if count == 0:
97
+ print(f" [SKIP] No pattern match in {rel_path}")
98
+ return False
99
+
100
+ if not dry_run:
101
+ with open(full_path, "w", encoding="utf-8", newline="\n") as f:
102
+ f.write(new_content)
103
+ print(f" [UPDATED] {rel_path} ({count} occurrence{'s' if count > 1 else ''})")
104
+ else:
105
+ print(f" [DRY-RUN] Would update {rel_path} ({count} occurrence{'s' if count > 1 else ''})")
106
+ return True
107
+
108
+ def run_tests() -> bool:
109
+ print("\n🔍 Running verification suite...")
110
+ cmd = [sys.executable, "-m", "unittest", "discover", "tests", "-p", "test_*.py"]
111
+ res = subprocess.run(cmd, cwd=REPO_ROOT)
112
+ if res.returncode != 0:
113
+ print("❌ Unit tests failed! Aborting release.")
114
+ return False
115
+ print("✅ All unit tests PASSED.")
116
+
117
+ verify_script = os.path.join(REPO_ROOT, "tools", "verify_all.py")
118
+ res_verify = subprocess.run([sys.executable, verify_script], cwd=REPO_ROOT)
119
+ if res_verify.returncode != 0:
120
+ print("❌ Quality Gates failed! Aborting release.")
121
+ return False
122
+ print("✅ 8 Quality Gates DoD PASSED.")
123
+ return True
124
+
125
+ def git_commit_and_tag(version: str, push: bool = False):
126
+ print(f"\n📦 Creating Git commit and tag v{version}...")
127
+ subprocess.run(["git", "add", "-A"], cwd=REPO_ROOT, check=True)
128
+ subprocess.run(["git", "commit", "-m", f"chore(release): bump version to v{version}"], cwd=REPO_ROOT, check=True)
129
+ subprocess.run(["git", "tag", f"v{version}"], cwd=REPO_ROOT, check=True)
130
+ print(f"✅ Git commit and tag v{version} created.")
131
+
132
+ if push:
133
+ print(f"🚀 Pushing commit and tags to remote origin...")
134
+ subprocess.run(["git", "push", "origin", "HEAD", "--tags"], cwd=REPO_ROOT, check=True)
135
+ print(f"✅ Remote origin synchronized.")
136
+
137
+ def main():
138
+ parser = argparse.ArgumentParser(description="Atomic Universal Version Bumper for Synapse Hub")
139
+ parser.add_argument("action", nargs="?", default="patch", help="patch | minor | major | <explicit_version> (default: patch)")
140
+ parser.add_argument("--dry-run", action="store_true", help="Preview version changes without writing files")
141
+ parser.add_argument("--no-test", action="store_true", help="Skip running test suite after bumping")
142
+ parser.add_argument("--commit", action="store_true", help="Auto-commit changes with conventional message")
143
+ parser.add_argument("--tag", action="store_true", help="Create Git tag v<version>")
144
+ parser.add_argument("--push", action="store_true", help="Push commit and tags to Git remote")
145
+
146
+ args = parser.parse_args()
147
+
148
+ current_ver = get_current_version()
149
+ new_ver = calculate_next_version(current_ver, args.action)
150
+
151
+ print("======================================================================")
152
+ print(" SYNAPSE HUB — ATOMIC UNIVERSAL VERSION BUMPER ")
153
+ print("======================================================================")
154
+ print(f" Current Version : {current_ver}")
155
+ print(f" Target Version : {new_ver} ({args.action})")
156
+ print(f" Mode : {'DRY RUN (Preview)' if args.dry_run else 'APPLY CHANGES'}")
157
+ print("======================================================================")
158
+
159
+ # 1. package.json
160
+ update_json_root_version("package.json", new_ver, args.dry_run)
161
+
162
+ # 2. pyproject.toml
163
+ update_file("pyproject.toml", r'(version\s*=\s*")[^"]+(")', rf"\g<1>{new_ver}\g<2>", args.dry_run)
164
+
165
+ # 3. skills_manifest.json
166
+ update_json_root_version("skills_manifest.json", new_ver, args.dry_run)
167
+
168
+ # 4. tools/cli.py
169
+ update_file("tools/cli.py", r'(version\s*=\s*")[^"]+(")', rf"\g<1>{new_ver}\g<2>", args.dry_run)
170
+
171
+ # 5. tools/mcp_server.py
172
+ update_file("tools/mcp_server.py", r'(SERVER_VERSION\s*=\s*")[^"]+(")', rf"\g<1>{new_ver}\g<2>", args.dry_run)
173
+
174
+ # 6. tools/browser_helpers.js
175
+ update_file("tools/browser_helpers.js", r"(__version === ')[^']+(')", rf"\g<1>{new_ver}\g<2>", args.dry_run)
176
+ update_file("tools/browser_helpers.js", r"(__version:\s*')[^']+(')", rf"\g<1>{new_ver}\g<2>", args.dry_run)
177
+
178
+ # 7. tools/mcp_excel_server.ps1
179
+ update_file("tools/mcp_excel_server.ps1", r'(version\s*=\s*")[^"]+(")', rf"\g<1>{new_ver}\g<2>", args.dry_run)
180
+
181
+ # 8. docs/ARCHITECTURE.md
182
+ update_file("docs/ARCHITECTURE.md", r'(>\s*\*\*Version:\*\*\s*`)[^`]+(`)', rf"\g<1>{new_ver}\g<2>", args.dry_run)
183
+
184
+ # 9. README.md (Documentation headings and mermaid badges)
185
+ update_file("README.md", r'(Phiên bản Hub:\*\*\s*`v)[^`]+(`)', rf"\g<1>{new_ver}\g<2>", args.dry_run)
186
+ update_file("README.md", r'(Hub Version:\*\*\s*`v)[^`]+(`)', rf"\g<1>{new_ver}\g<2>", args.dry_run)
187
+ update_file("README.md", r'(Intelligence Core v)[^")\]]+', rf"\g<1>{new_ver}", args.dry_run)
188
+
189
+ if args.dry_run:
190
+ print("\n[INFO] Dry-run completed. No files modified on disk.")
191
+ return
192
+
193
+ # Tests verification
194
+ if not args.no_test:
195
+ if not run_tests():
196
+ sys.exit(1)
197
+
198
+ # Git operations
199
+ if args.commit or args.push:
200
+ git_commit_and_tag(new_ver, push=args.push)
201
+
202
+ print("\n======================================================================")
203
+ print(f"🎉 VERSION BUMP COMPLETE: v{current_ver} ➔ v{new_ver}")
204
+ print("======================================================================")
205
+ print("Next steps:")
206
+ print(f" • Publish to NPM : npm publish")
207
+ print(f" • Or Git Push : git push origin HEAD --tags")
208
+ print("======================================================================")
209
+
210
+ if __name__ == "__main__":
211
+ main()
@@ -225,6 +225,22 @@ def init_db(seed=True, target_db=None):
225
225
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
226
226
  )
227
227
  """)
228
+
229
+ # 4. Flaky Test Historical Telemetry & Quarantine Matrix
230
+ cursor.execute("""
231
+ CREATE TABLE IF NOT EXISTS test_telemetry (
232
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
233
+ test_id TEXT NOT NULL,
234
+ run_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
235
+ status TEXT NOT NULL,
236
+ is_retry INTEGER DEFAULT 0,
237
+ duration_ms REAL DEFAULT 0.0,
238
+ error_signature TEXT DEFAULT '',
239
+ context TEXT DEFAULT ''
240
+ )
241
+ """)
242
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_telemetry_test_id ON test_telemetry (test_id)")
243
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON test_telemetry (run_timestamp)")
228
244
  conn.commit()
229
245
 
230
246
  # Migrate existing solutions from local DB if initializing global DB for the first time
@@ -667,6 +683,72 @@ def query_lessons(context_or_keyword, limit=2):
667
683
  return top_results
668
684
 
669
685
 
686
+ @retry_on_locked()
687
+ def record_test_telemetry(test_id, status, is_retry=0, duration_ms=0.0, error_signature="", context="", target_db=None):
688
+ """
689
+ Records an individual test execution into persistent SQLite telemetry.
690
+ Used for computing 30-day historical flakiness index and auto-quarantine decisions.
691
+ """
692
+ init_db(seed=True, target_db=target_db)
693
+ current_db = target_db or DB_FILE
694
+ with get_db(current_db) as conn:
695
+ cursor = conn.cursor()
696
+ cursor.execute("""
697
+ INSERT INTO test_telemetry (test_id, status, is_retry, duration_ms, error_signature, context)
698
+ VALUES (?, ?, ?, ?, ?, ?)
699
+ """, (test_id, status.upper(), int(is_retry), float(duration_ms), error_signature, context))
700
+ conn.commit()
701
+ return True
702
+
703
+
704
+ def get_flaky_telemetry(days=30, min_runs=2, threshold=0.15, target_db=None):
705
+ """
706
+ Queries historical test telemetry and calculates the Flaky Ratio across the given window.
707
+ Flaky Ratio = (retry_pass_count + fail_then_pass_transitions) / total_runs.
708
+ """
709
+ init_db(seed=True, target_db=target_db)
710
+ current_db = target_db or DB_FILE
711
+ with get_db(current_db) as conn:
712
+ cursor = conn.cursor()
713
+ cursor.execute("""
714
+ SELECT
715
+ test_id,
716
+ COUNT(*) as total_runs,
717
+ SUM(CASE WHEN status = 'PASS' THEN 1 ELSE 0 END) as pass_count,
718
+ SUM(CASE WHEN status = 'FAIL' THEN 1 ELSE 0 END) as fail_count,
719
+ SUM(CASE WHEN status = 'PASS' AND is_retry = 1 THEN 1 ELSE 0 END) as retry_pass_count,
720
+ AVG(duration_ms) as avg_duration_ms
721
+ FROM test_telemetry
722
+ WHERE run_timestamp >= datetime('now', '-' || ? || ' days')
723
+ GROUP BY test_id
724
+ HAVING total_runs >= ?
725
+ """, (days, min_runs))
726
+ rows = cursor.fetchall()
727
+ candidates = []
728
+ for r in rows:
729
+ total = r["total_runs"]
730
+ retry_passes = r["retry_pass_count"] or 0
731
+ fails = r["fail_count"] or 0
732
+ passes = r["pass_count"] or 0
733
+ # If test both passes and fails across runs, or passes on retry
734
+ flaky_events = retry_passes + (1 if fails > 0 and passes > 0 else 0)
735
+ flaky_ratio = round(min(1.0, flaky_events / max(1, total)), 3)
736
+ quarantine = flaky_ratio >= threshold and passes > 0
737
+
738
+ candidates.append({
739
+ "test_id": r["test_id"],
740
+ "total_runs": total,
741
+ "pass_count": passes,
742
+ "fail_count": fails,
743
+ "retry_pass_count": retry_passes,
744
+ "flaky_ratio": flaky_ratio,
745
+ "avg_duration_ms": round(r["avg_duration_ms"] or 0.0, 1),
746
+ "quarantine_recommended": quarantine
747
+ })
748
+ candidates.sort(key=lambda x: x["flaky_ratio"], reverse=True)
749
+ return candidates
750
+
751
+
670
752
  def get_stats():
671
753
  init_db(seed=True)
672
754
  with get_db() as conn:
@@ -683,6 +765,9 @@ def get_stats():
683
765
  cursor.execute("SELECT COUNT(*) as total_lessons FROM lessons_learned")
684
766
  total_lessons = cursor.fetchone()["total_lessons"] or 0
685
767
 
768
+ cursor.execute("SELECT COUNT(*) as total_telemetry FROM test_telemetry")
769
+ total_telemetry = cursor.fetchone()["total_telemetry"] or 0
770
+
686
771
  cursor.execute("SELECT error_signature, module_name, hit_count FROM healing_cache ORDER BY hit_count DESC")
687
772
  top_solutions = [dict(r) for r in cursor.fetchall()]
688
773
 
@@ -691,6 +776,7 @@ def get_stats():
691
776
  "total_hits": total_hits,
692
777
  "total_failed_fixes": total_failed,
693
778
  "total_lessons_learned": total_lessons,
779
+ "total_telemetry_records": total_telemetry,
694
780
  "estimated_tokens_saved": saved_tokens,
695
781
  "solutions": top_solutions
696
782
  }
@@ -736,6 +822,21 @@ def main():
736
822
  lesson_get_parser.add_argument("--context", required=True, help="Page, flow, or error keyword to match")
737
823
  lesson_get_parser.add_argument("--limit", type=int, default=2, help="Number of lessons to retrieve (default: 2)")
738
824
 
825
+ # Record-Telemetry command (Historical Telemetry)
826
+ telem_record_parser = subparsers.add_parser("record-telemetry", help="Record individual test run outcome into telemetry")
827
+ telem_record_parser.add_argument("--test-id", required=True, help="Test case identifier or class#method")
828
+ telem_record_parser.add_argument("--status", required=True, choices=["PASS", "FAIL"], help="Execution status")
829
+ telem_record_parser.add_argument("--retry", type=int, default=0, help="Whether this run was an automated retry (1 or 0)")
830
+ telem_record_parser.add_argument("--duration", type=float, default=0.0, help="Run duration in ms")
831
+ telem_record_parser.add_argument("--error", default="", help="Error signature if failed")
832
+ telem_record_parser.add_argument("--context", default="", help="Environment or workflow context")
833
+
834
+ # Get-Flaky command (Historical Flakiness Query)
835
+ flaky_get_parser = subparsers.add_parser("get-flaky", help="Query historical flaky test candidates from telemetry")
836
+ flaky_get_parser.add_argument("--days", type=int, default=30, help="Lookback window in days (default: 30)")
837
+ flaky_get_parser.add_argument("--min-runs", type=int, default=2, help="Minimum runs required (default: 2)")
838
+ flaky_get_parser.add_argument("--threshold", type=float, default=0.15, help="Flaky ratio threshold for quarantine (default: 0.15)")
839
+
739
840
  # Stats command
740
841
  subparsers.add_parser("stats", help="View memory cache statistics (Positive & Negative)")
741
842
 
@@ -817,6 +918,33 @@ def main():
817
918
  print(f" ✅ Proven Fix : {l.get('successful_fix') or 'N/A'}")
818
919
  print("==================================================")
819
920
 
921
+ elif args.command == "record-telemetry":
922
+ ok = record_test_telemetry(
923
+ args.test_id,
924
+ args.status,
925
+ is_retry=args.retry,
926
+ duration_ms=args.duration,
927
+ error_signature=args.error,
928
+ context=args.context
929
+ )
930
+ if ok:
931
+ print(f"📊 [TELEMETRY RECORDED] {args.test_id} -> {args.status} (Retry={args.retry}, {args.duration}ms)")
932
+
933
+ elif args.command == "get-flaky":
934
+ flaky_list = get_flaky_telemetry(days=args.days, min_runs=args.min_runs, threshold=args.threshold)
935
+ print("==================================================")
936
+ print(f"📈 [HISTORICAL FLAKINESS TELEMETRY ({args.days} Days)]")
937
+ print("==================================================")
938
+ if not flaky_list:
939
+ print(" (Zero flaky tests detected above threshold. Suite is stable.)")
940
+ else:
941
+ for item in flaky_list:
942
+ status_icon = "🚨 [QUARANTINE]" if item["quarantine_recommended"] else "ℹ️ [OBSERVE]"
943
+ print(f"{status_icon} {item['test_id']}")
944
+ print(f" Runs: {item['total_runs']} | Passes: {item['pass_count']} | Fails: {item['fail_count']} | Retry Passes: {item['retry_pass_count']}")
945
+ print(f" Flaky Ratio: {item['flaky_ratio']*100:.1f}% | Avg Duration: {item['avg_duration_ms']}ms")
946
+ print("==================================================")
947
+
820
948
  elif args.command == "stats":
821
949
  stats = get_stats()
822
950
  print("==================================================")
package/tools/cli.py CHANGED
@@ -25,7 +25,7 @@ sys.path.insert(0, SCRIPT_DIR)
25
25
 
26
26
  def cmd_version(args):
27
27
  manifest_path = os.path.join(REPO_ROOT, "skills_manifest.json")
28
- version = "2.0.0"
28
+ version = "2.1.0"
29
29
  if os.path.exists(manifest_path):
30
30
  try:
31
31
  import json
@@ -257,6 +257,30 @@ def cmd_decouple_config(args):
257
257
  sys.exit(decouple_main())
258
258
 
259
259
 
260
+ def cmd_fuzz_api(args):
261
+ from api_fuzzer import main as fuzz_main
262
+ sys.argv = [sys.argv[0]] + (sys.argv[2:] if len(sys.argv) > 2 else [])
263
+ sys.exit(fuzz_main())
264
+
265
+
266
+ def cmd_som(args):
267
+ from visual_som_helper import main as som_main
268
+ sys.argv = [sys.argv[0]] + (sys.argv[2:] if len(sys.argv) > 2 else [])
269
+ sys.exit(som_main())
270
+
271
+
272
+ def cmd_export_postman(args):
273
+ from export_postman_collection import main as postman_main
274
+ sys.argv = [sys.argv[0]] + (sys.argv[2:] if len(sys.argv) > 2 else [])
275
+ sys.exit(postman_main())
276
+
277
+
278
+ def cmd_generate_jmx(args):
279
+ from generate_jmx_plan import main as jmx_main
280
+ sys.argv = [sys.argv[0]] + (sys.argv[2:] if len(sys.argv) > 2 else [])
281
+ sys.exit(jmx_main())
282
+
283
+
260
284
  def main():
261
285
  parser = argparse.ArgumentParser(
262
286
  prog="agent-qa",
@@ -385,6 +409,22 @@ def main():
385
409
  p_decouple.add_argument("--out-dir", default=None, help="Output directory for config profiles")
386
410
  p_decouple.set_defaults(func=cmd_decouple_config)
387
411
 
412
+ # fuzz-api
413
+ p_fuzz = subparsers.add_parser("fuzz-api", help="Autonomous API Fuzzing & Chaos Breaker (bắn mutation payloads, bắt 500, DB leak, SLA breach)")
414
+ p_fuzz.set_defaults(func=cmd_fuzz_api)
415
+
416
+ # som (Visual Grounding for Canvas / GIS)
417
+ p_som = subparsers.add_parser("som", help="Multimodal Vision Grounding & Set-of-Marks (SoM) for Canvas/GIS without DOM")
418
+ p_som.set_defaults(func=cmd_som)
419
+
420
+ # export-postman
421
+ p_postman = subparsers.add_parser("export-postman", help="Export OpenAPI / Swagger / HAR to Postman Collection v2.1.0 with auto-asserts")
422
+ p_postman.set_defaults(func=cmd_export_postman)
423
+
424
+ # generate-jmx
425
+ p_jmx = subparsers.add_parser("generate-jmx", help="Generate Apache JMeter 5.x Test Plan XML (.jmx) from OpenAPI / Postman / HAR")
426
+ p_jmx.set_defaults(func=cmd_generate_jmx)
427
+
388
428
  args, unknown = parser.parse_known_args()
389
429
  if getattr(args, "offline", False):
390
430
  os.environ["AGENT_QA_OFFLINE"] = "1"