empathy-framework 3.8.3__py3-none-any.whl → 3.9.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.
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/METADATA +67 -7
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/RECORD +50 -39
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/top_level.txt +0 -4
- empathy_os/.empathy/costs.json +60 -0
- empathy_os/.empathy/discovery_stats.json +15 -0
- empathy_os/.empathy/workflow_runs.json +45 -0
- empathy_os/cli.py +372 -13
- empathy_os/cli_unified.py +111 -0
- empathy_os/config/xml_config.py +45 -3
- empathy_os/config.py +46 -2
- empathy_os/memory/control_panel.py +128 -8
- empathy_os/memory/long_term.py +26 -4
- empathy_os/memory/short_term.py +110 -0
- empathy_os/models/token_estimator.py +25 -0
- empathy_os/pattern_library.py +81 -8
- empathy_os/patterns/debugging/all_patterns.json +81 -0
- empathy_os/patterns/debugging/workflow_20260107_1770825e.json +77 -0
- empathy_os/patterns/refactoring_memory.json +89 -0
- empathy_os/telemetry/__init__.py +11 -0
- empathy_os/telemetry/cli.py +451 -0
- empathy_os/telemetry/usage_tracker.py +475 -0
- {test_generator → empathy_os/test_generator}/generator.py +1 -0
- empathy_os/tier_recommender.py +422 -0
- empathy_os/workflows/base.py +223 -23
- empathy_os/workflows/config.py +50 -5
- empathy_os/workflows/tier_tracking.py +408 -0
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/WHEEL +0 -0
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/entry_points.txt +0 -0
- {empathy_framework-3.8.3.dist-info → empathy_framework-3.9.1.dist-info}/licenses/LICENSE +0 -0
- {hot_reload → empathy_os/hot_reload}/README.md +0 -0
- {hot_reload → empathy_os/hot_reload}/__init__.py +0 -0
- {hot_reload → empathy_os/hot_reload}/config.py +0 -0
- {hot_reload → empathy_os/hot_reload}/integration.py +0 -0
- {hot_reload → empathy_os/hot_reload}/reloader.py +0 -0
- {hot_reload → empathy_os/hot_reload}/watcher.py +0 -0
- {hot_reload → empathy_os/hot_reload}/websocket.py +0 -0
- {scaffolding → empathy_os/scaffolding}/README.md +0 -0
- {scaffolding → empathy_os/scaffolding}/__init__.py +0 -0
- {scaffolding → empathy_os/scaffolding}/__main__.py +0 -0
- {scaffolding → empathy_os/scaffolding}/cli.py +0 -0
- {test_generator → empathy_os/test_generator}/__init__.py +0 -0
- {test_generator → empathy_os/test_generator}/__main__.py +0 -0
- {test_generator → empathy_os/test_generator}/cli.py +0 -0
- {test_generator → empathy_os/test_generator}/risk_analyzer.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/__init__.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/behavior.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/core.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/output.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/registry.py +0 -0
- {workflow_patterns → empathy_os/workflow_patterns}/structural.py +0 -0
empathy_os/workflows/base.py
CHANGED
|
@@ -17,6 +17,7 @@ from __future__ import annotations
|
|
|
17
17
|
|
|
18
18
|
import json
|
|
19
19
|
import logging
|
|
20
|
+
import time
|
|
20
21
|
import uuid
|
|
21
22
|
from abc import ABC, abstractmethod
|
|
22
23
|
from dataclasses import dataclass, field
|
|
@@ -53,6 +54,15 @@ from empathy_os.models import ModelTier as UnifiedModelTier
|
|
|
53
54
|
# Import progress tracking
|
|
54
55
|
from .progress import ProgressCallback, ProgressTracker
|
|
55
56
|
|
|
57
|
+
# Import telemetry tracking
|
|
58
|
+
try:
|
|
59
|
+
from empathy_os.telemetry import UsageTracker
|
|
60
|
+
|
|
61
|
+
TELEMETRY_AVAILABLE = True
|
|
62
|
+
except ImportError:
|
|
63
|
+
TELEMETRY_AVAILABLE = False
|
|
64
|
+
UsageTracker = None # type: ignore
|
|
65
|
+
|
|
56
66
|
if TYPE_CHECKING:
|
|
57
67
|
from .config import WorkflowConfig
|
|
58
68
|
from .step_config import WorkflowStepConfig
|
|
@@ -376,6 +386,7 @@ class BaseWorkflow(ABC):
|
|
|
376
386
|
progress_callback: ProgressCallback | None = None,
|
|
377
387
|
cache: BaseCache | None = None,
|
|
378
388
|
enable_cache: bool = True,
|
|
389
|
+
enable_tier_tracking: bool = True,
|
|
379
390
|
):
|
|
380
391
|
"""Initialize workflow with optional cost tracker, provider, and config.
|
|
381
392
|
|
|
@@ -394,6 +405,7 @@ class BaseWorkflow(ABC):
|
|
|
394
405
|
cache: Optional cache instance. If None and enable_cache=True,
|
|
395
406
|
auto-creates cache with one-time setup prompt.
|
|
396
407
|
enable_cache: Whether to enable caching (default True).
|
|
408
|
+
enable_tier_tracking: Whether to enable automatic tier tracking (default True).
|
|
397
409
|
|
|
398
410
|
"""
|
|
399
411
|
from .config import WorkflowConfig
|
|
@@ -416,6 +428,25 @@ class BaseWorkflow(ABC):
|
|
|
416
428
|
self._enable_cache = enable_cache
|
|
417
429
|
self._cache_setup_attempted = False
|
|
418
430
|
|
|
431
|
+
# Tier tracking support
|
|
432
|
+
self._enable_tier_tracking = enable_tier_tracking
|
|
433
|
+
self._tier_tracker = None
|
|
434
|
+
|
|
435
|
+
# Telemetry tracking (singleton instance)
|
|
436
|
+
self._telemetry_tracker: UsageTracker | None = None
|
|
437
|
+
self._enable_telemetry = True # Enable by default
|
|
438
|
+
if TELEMETRY_AVAILABLE and UsageTracker is not None:
|
|
439
|
+
try:
|
|
440
|
+
self._telemetry_tracker = UsageTracker.get_instance()
|
|
441
|
+
except (OSError, PermissionError) as e:
|
|
442
|
+
# File system errors - log but disable telemetry
|
|
443
|
+
logger.debug(f"Failed to initialize telemetry tracker (file system error): {e}")
|
|
444
|
+
self._enable_telemetry = False
|
|
445
|
+
except (AttributeError, TypeError, ValueError) as e:
|
|
446
|
+
# Configuration or initialization errors
|
|
447
|
+
logger.debug(f"Failed to initialize telemetry tracker (config error): {e}")
|
|
448
|
+
self._enable_telemetry = False
|
|
449
|
+
|
|
419
450
|
# Load config if not provided
|
|
420
451
|
self._config = config or WorkflowConfig.load()
|
|
421
452
|
|
|
@@ -475,15 +506,19 @@ class BaseWorkflow(ABC):
|
|
|
475
506
|
auto_setup_cache()
|
|
476
507
|
self._cache = create_cache()
|
|
477
508
|
logger.info(f"Cache initialized for workflow: {self.name}")
|
|
478
|
-
except ImportError:
|
|
509
|
+
except ImportError as e:
|
|
479
510
|
# Hybrid cache dependencies not available, fall back to hash-only
|
|
480
511
|
logger.info(
|
|
481
|
-
"Using hash-only cache (install empathy-framework[cache] for semantic caching)"
|
|
512
|
+
f"Using hash-only cache (install empathy-framework[cache] for semantic caching): {e}"
|
|
482
513
|
)
|
|
483
514
|
self._cache = create_cache(cache_type="hash")
|
|
484
|
-
except
|
|
485
|
-
#
|
|
486
|
-
logger.warning("Cache setup failed, continuing without cache")
|
|
515
|
+
except (OSError, PermissionError) as e:
|
|
516
|
+
# File system errors - disable cache
|
|
517
|
+
logger.warning(f"Cache setup failed (file system error): {e}, continuing without cache")
|
|
518
|
+
self._enable_cache = False
|
|
519
|
+
except (ValueError, TypeError, AttributeError) as e:
|
|
520
|
+
# Configuration errors - disable cache
|
|
521
|
+
logger.warning(f"Cache setup failed (config error): {e}, continuing without cache")
|
|
487
522
|
self._enable_cache = False
|
|
488
523
|
|
|
489
524
|
async def _call_llm(
|
|
@@ -500,6 +535,7 @@ class BaseWorkflow(ABC):
|
|
|
500
535
|
that respect the configured provider (anthropic, openai, google, etc.).
|
|
501
536
|
|
|
502
537
|
Supports automatic caching to reduce API costs and latency.
|
|
538
|
+
Tracks telemetry for usage analysis and cost savings measurement.
|
|
503
539
|
|
|
504
540
|
Args:
|
|
505
541
|
tier: Model tier to use (CHEAP, CAPABLE, PREMIUM)
|
|
@@ -514,9 +550,13 @@ class BaseWorkflow(ABC):
|
|
|
514
550
|
"""
|
|
515
551
|
from .step_config import WorkflowStepConfig
|
|
516
552
|
|
|
553
|
+
# Start timing for telemetry
|
|
554
|
+
start_time = time.time()
|
|
555
|
+
|
|
517
556
|
# Determine stage name for cache key
|
|
518
557
|
stage = stage_name or f"llm_call_{tier.value}"
|
|
519
558
|
model = self.get_model_for_tier(tier)
|
|
559
|
+
cache_type = None
|
|
520
560
|
|
|
521
561
|
# Try cache lookup if enabled
|
|
522
562
|
if self._enable_cache and self._cache is not None:
|
|
@@ -527,15 +567,45 @@ class BaseWorkflow(ABC):
|
|
|
527
567
|
|
|
528
568
|
if cached_response is not None:
|
|
529
569
|
logger.debug(f"Cache hit for {self.name}:{stage}")
|
|
570
|
+
# Determine cache type
|
|
571
|
+
if hasattr(self._cache, "cache_type"):
|
|
572
|
+
ct = self._cache.cache_type # type: ignore
|
|
573
|
+
# Ensure it's a string (not a Mock object)
|
|
574
|
+
cache_type = str(ct) if ct and isinstance(ct, str) else "hash"
|
|
575
|
+
else:
|
|
576
|
+
cache_type = "hash" # Default assumption
|
|
577
|
+
|
|
578
|
+
# Track telemetry for cache hit
|
|
579
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
580
|
+
in_tokens = cached_response["input_tokens"]
|
|
581
|
+
out_tokens = cached_response["output_tokens"]
|
|
582
|
+
cost = self._calculate_cost(tier, in_tokens, out_tokens)
|
|
583
|
+
|
|
584
|
+
self._track_telemetry(
|
|
585
|
+
stage=stage,
|
|
586
|
+
tier=tier,
|
|
587
|
+
model=model,
|
|
588
|
+
cost=cost,
|
|
589
|
+
tokens={"input": in_tokens, "output": out_tokens},
|
|
590
|
+
cache_hit=True,
|
|
591
|
+
cache_type=cache_type,
|
|
592
|
+
duration_ms=duration_ms,
|
|
593
|
+
)
|
|
594
|
+
|
|
530
595
|
# Cached response is dict with content, input_tokens, output_tokens
|
|
531
596
|
return (
|
|
532
597
|
cached_response["content"],
|
|
533
598
|
cached_response["input_tokens"],
|
|
534
599
|
cached_response["output_tokens"],
|
|
535
600
|
)
|
|
536
|
-
except
|
|
537
|
-
#
|
|
538
|
-
logger.debug("Cache lookup failed, continuing with LLM call")
|
|
601
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
602
|
+
# Malformed cache data - continue with LLM call
|
|
603
|
+
logger.debug(f"Cache lookup failed (malformed data): {e}, continuing with LLM call")
|
|
604
|
+
except (OSError, PermissionError) as e:
|
|
605
|
+
# File system errors - continue with LLM call
|
|
606
|
+
logger.debug(
|
|
607
|
+
f"Cache lookup failed (file system error): {e}, continuing with LLM call"
|
|
608
|
+
)
|
|
539
609
|
|
|
540
610
|
# Create a step config for this call
|
|
541
611
|
step = WorkflowStepConfig(
|
|
@@ -547,12 +617,27 @@ class BaseWorkflow(ABC):
|
|
|
547
617
|
)
|
|
548
618
|
|
|
549
619
|
try:
|
|
550
|
-
content, in_tokens, out_tokens,
|
|
620
|
+
content, in_tokens, out_tokens, cost = await self.run_step_with_executor(
|
|
551
621
|
step=step,
|
|
552
622
|
prompt=user_message,
|
|
553
623
|
system=system,
|
|
554
624
|
)
|
|
555
625
|
|
|
626
|
+
# Calculate duration
|
|
627
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
628
|
+
|
|
629
|
+
# Track telemetry for actual LLM call
|
|
630
|
+
self._track_telemetry(
|
|
631
|
+
stage=stage,
|
|
632
|
+
tier=tier,
|
|
633
|
+
model=model,
|
|
634
|
+
cost=cost,
|
|
635
|
+
tokens={"input": in_tokens, "output": out_tokens},
|
|
636
|
+
cache_hit=False,
|
|
637
|
+
cache_type=None,
|
|
638
|
+
duration_ms=duration_ms,
|
|
639
|
+
)
|
|
640
|
+
|
|
556
641
|
# Store in cache if enabled
|
|
557
642
|
if self._enable_cache and self._cache is not None:
|
|
558
643
|
try:
|
|
@@ -564,21 +649,78 @@ class BaseWorkflow(ABC):
|
|
|
564
649
|
}
|
|
565
650
|
self._cache.put(self.name, stage, full_prompt, model, response_data)
|
|
566
651
|
logger.debug(f"Cached response for {self.name}:{stage}")
|
|
567
|
-
except
|
|
568
|
-
#
|
|
569
|
-
logger.debug("Failed to cache response")
|
|
652
|
+
except (OSError, PermissionError) as e:
|
|
653
|
+
# File system errors - log but continue
|
|
654
|
+
logger.debug(f"Failed to cache response (file system error): {e}")
|
|
655
|
+
except (ValueError, TypeError, KeyError) as e:
|
|
656
|
+
# Data serialization errors - log but continue
|
|
657
|
+
logger.debug(f"Failed to cache response (serialization error): {e}")
|
|
570
658
|
|
|
571
659
|
return content, in_tokens, out_tokens
|
|
572
660
|
except (ValueError, TypeError, KeyError) as e:
|
|
573
661
|
# Invalid input or configuration errors
|
|
662
|
+
logger.warning(f"LLM call failed (invalid input): {e}")
|
|
574
663
|
return f"Error calling LLM (invalid input): {e}", 0, 0
|
|
575
|
-
except (TimeoutError, RuntimeError) as e:
|
|
576
|
-
# Timeout or
|
|
664
|
+
except (TimeoutError, RuntimeError, ConnectionError) as e:
|
|
665
|
+
# Timeout, API errors, or connection failures
|
|
666
|
+
logger.warning(f"LLM call failed (timeout/API/connection error): {e}")
|
|
577
667
|
return f"Error calling LLM (timeout/API error): {e}", 0, 0
|
|
578
|
-
except
|
|
668
|
+
except (OSError, PermissionError) as e:
|
|
669
|
+
# File system or permission errors
|
|
670
|
+
logger.warning(f"LLM call failed (file system error): {e}")
|
|
671
|
+
return f"Error calling LLM (file system error): {e}", 0, 0
|
|
672
|
+
except Exception as e:
|
|
579
673
|
# INTENTIONAL: Graceful degradation - return error message rather than crashing workflow
|
|
580
|
-
logger.exception("Unexpected error calling LLM")
|
|
581
|
-
return "Error calling LLM:
|
|
674
|
+
logger.exception(f"Unexpected error calling LLM: {e}")
|
|
675
|
+
return f"Error calling LLM: {type(e).__name__}", 0, 0
|
|
676
|
+
|
|
677
|
+
def _track_telemetry(
|
|
678
|
+
self,
|
|
679
|
+
stage: str,
|
|
680
|
+
tier: ModelTier,
|
|
681
|
+
model: str,
|
|
682
|
+
cost: float,
|
|
683
|
+
tokens: dict[str, int],
|
|
684
|
+
cache_hit: bool,
|
|
685
|
+
cache_type: str | None,
|
|
686
|
+
duration_ms: int,
|
|
687
|
+
) -> None:
|
|
688
|
+
"""Track telemetry for an LLM call.
|
|
689
|
+
|
|
690
|
+
Args:
|
|
691
|
+
stage: Stage name
|
|
692
|
+
tier: Model tier used
|
|
693
|
+
model: Model ID used
|
|
694
|
+
cost: Cost in USD
|
|
695
|
+
tokens: Dictionary with "input" and "output" token counts
|
|
696
|
+
cache_hit: Whether this was a cache hit
|
|
697
|
+
cache_type: Cache type if cache hit
|
|
698
|
+
duration_ms: Duration in milliseconds
|
|
699
|
+
|
|
700
|
+
"""
|
|
701
|
+
if not self._enable_telemetry or self._telemetry_tracker is None:
|
|
702
|
+
return
|
|
703
|
+
|
|
704
|
+
try:
|
|
705
|
+
provider_str = getattr(self, "_provider_str", "unknown")
|
|
706
|
+
self._telemetry_tracker.track_llm_call(
|
|
707
|
+
workflow=self.name,
|
|
708
|
+
stage=stage,
|
|
709
|
+
tier=tier.value.upper(),
|
|
710
|
+
model=model,
|
|
711
|
+
provider=provider_str,
|
|
712
|
+
cost=cost,
|
|
713
|
+
tokens=tokens,
|
|
714
|
+
cache_hit=cache_hit,
|
|
715
|
+
cache_type=cache_type,
|
|
716
|
+
duration_ms=duration_ms,
|
|
717
|
+
)
|
|
718
|
+
except (AttributeError, TypeError, ValueError) as e:
|
|
719
|
+
# INTENTIONAL: Telemetry tracking failures should never crash workflows
|
|
720
|
+
logger.debug(f"Failed to track telemetry (config/data error): {e}")
|
|
721
|
+
except (OSError, PermissionError) as e:
|
|
722
|
+
# File system errors - log but never crash workflow
|
|
723
|
+
logger.debug(f"Failed to track telemetry (file system error): {e}")
|
|
582
724
|
|
|
583
725
|
def _calculate_cost(self, tier: ModelTier, input_tokens: int, output_tokens: int) -> float:
|
|
584
726
|
"""Calculate cost for a stage."""
|
|
@@ -709,6 +851,20 @@ class BaseWorkflow(ABC):
|
|
|
709
851
|
# Set run ID for telemetry correlation
|
|
710
852
|
self._run_id = str(uuid.uuid4())
|
|
711
853
|
|
|
854
|
+
# Auto tier recommendation
|
|
855
|
+
if self._enable_tier_tracking:
|
|
856
|
+
try:
|
|
857
|
+
from .tier_tracking import WorkflowTierTracker
|
|
858
|
+
|
|
859
|
+
self._tier_tracker = WorkflowTierTracker(self.name, self.description)
|
|
860
|
+
files_affected = kwargs.get("files_affected") or kwargs.get("path")
|
|
861
|
+
if files_affected and not isinstance(files_affected, list):
|
|
862
|
+
files_affected = [str(files_affected)]
|
|
863
|
+
self._tier_tracker.show_recommendation(files_affected)
|
|
864
|
+
except Exception as e:
|
|
865
|
+
logger.debug(f"Tier tracking disabled: {e}")
|
|
866
|
+
self._enable_tier_tracking = False
|
|
867
|
+
|
|
712
868
|
started_at = datetime.now()
|
|
713
869
|
self._stages_run = []
|
|
714
870
|
current_data = kwargs
|
|
@@ -793,6 +949,18 @@ class BaseWorkflow(ABC):
|
|
|
793
949
|
task_type=f"workflow:{self.name}:{stage_name}",
|
|
794
950
|
)
|
|
795
951
|
|
|
952
|
+
# Track telemetry for this stage
|
|
953
|
+
self._track_telemetry(
|
|
954
|
+
stage=stage_name,
|
|
955
|
+
tier=tier,
|
|
956
|
+
model=model_id,
|
|
957
|
+
cost=cost,
|
|
958
|
+
tokens={"input": input_tokens, "output": output_tokens},
|
|
959
|
+
cache_hit=False,
|
|
960
|
+
cache_type=None,
|
|
961
|
+
duration_ms=duration_ms,
|
|
962
|
+
)
|
|
963
|
+
|
|
796
964
|
# Pass output to next stage
|
|
797
965
|
current_data = output if isinstance(output, dict) else {"result": output}
|
|
798
966
|
|
|
@@ -802,16 +970,22 @@ class BaseWorkflow(ABC):
|
|
|
802
970
|
logger.error(error)
|
|
803
971
|
if self._progress_tracker:
|
|
804
972
|
self._progress_tracker.fail_workflow(error)
|
|
805
|
-
except (TimeoutError, RuntimeError) as e:
|
|
806
|
-
# Timeout or
|
|
807
|
-
error = f"Workflow execution error (timeout/API): {e}"
|
|
973
|
+
except (TimeoutError, RuntimeError, ConnectionError) as e:
|
|
974
|
+
# Timeout, API errors, or connection failures
|
|
975
|
+
error = f"Workflow execution error (timeout/API/connection): {e}"
|
|
808
976
|
logger.error(error)
|
|
809
977
|
if self._progress_tracker:
|
|
810
978
|
self._progress_tracker.fail_workflow(error)
|
|
811
|
-
except
|
|
979
|
+
except (OSError, PermissionError) as e:
|
|
980
|
+
# File system or permission errors
|
|
981
|
+
error = f"Workflow execution error (file system): {e}"
|
|
982
|
+
logger.error(error)
|
|
983
|
+
if self._progress_tracker:
|
|
984
|
+
self._progress_tracker.fail_workflow(error)
|
|
985
|
+
except Exception as e:
|
|
812
986
|
# INTENTIONAL: Workflow orchestration - catch all errors to report failure gracefully
|
|
813
|
-
logger.exception("Unexpected error in workflow execution")
|
|
814
|
-
error = "Workflow execution failed:
|
|
987
|
+
logger.exception(f"Unexpected error in workflow execution: {type(e).__name__}")
|
|
988
|
+
error = f"Workflow execution failed: {type(e).__name__}"
|
|
815
989
|
if self._progress_tracker:
|
|
816
990
|
self._progress_tracker.fail_workflow(error)
|
|
817
991
|
|
|
@@ -881,6 +1055,32 @@ class BaseWorkflow(ABC):
|
|
|
881
1055
|
# Emit workflow telemetry to backend
|
|
882
1056
|
self._emit_workflow_telemetry(result)
|
|
883
1057
|
|
|
1058
|
+
# Auto-save tier progression
|
|
1059
|
+
if self._enable_tier_tracking and self._tier_tracker:
|
|
1060
|
+
try:
|
|
1061
|
+
files_affected = kwargs.get("files_affected") or kwargs.get("path")
|
|
1062
|
+
if files_affected and not isinstance(files_affected, list):
|
|
1063
|
+
files_affected = [str(files_affected)]
|
|
1064
|
+
|
|
1065
|
+
# Determine bug type from workflow name
|
|
1066
|
+
bug_type_map = {
|
|
1067
|
+
"code-review": "code_quality",
|
|
1068
|
+
"bug-predict": "bug_prediction",
|
|
1069
|
+
"security-audit": "security_issue",
|
|
1070
|
+
"test-gen": "test_coverage",
|
|
1071
|
+
"refactor-plan": "refactoring",
|
|
1072
|
+
"health-check": "health_check",
|
|
1073
|
+
}
|
|
1074
|
+
bug_type = bug_type_map.get(self.name, "workflow_run")
|
|
1075
|
+
|
|
1076
|
+
self._tier_tracker.save_progression(
|
|
1077
|
+
workflow_result=result,
|
|
1078
|
+
files_affected=files_affected,
|
|
1079
|
+
bug_type=bug_type,
|
|
1080
|
+
)
|
|
1081
|
+
except Exception as e:
|
|
1082
|
+
logger.debug(f"Failed to save tier progression: {e}")
|
|
1083
|
+
|
|
884
1084
|
return result
|
|
885
1085
|
|
|
886
1086
|
def describe(self) -> str:
|
empathy_os/workflows/config.py
CHANGED
|
@@ -38,6 +38,48 @@ except ImportError:
|
|
|
38
38
|
YAML_AVAILABLE = False
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
def _validate_file_path(path: str, allowed_dir: str | None = None) -> Path:
|
|
42
|
+
"""Validate file path to prevent path traversal and arbitrary writes.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
path: File path to validate
|
|
46
|
+
allowed_dir: Optional directory to restrict writes to
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Validated Path object
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If path is invalid or unsafe
|
|
53
|
+
"""
|
|
54
|
+
if not path or not isinstance(path, str):
|
|
55
|
+
raise ValueError("path must be a non-empty string")
|
|
56
|
+
|
|
57
|
+
# Check for null bytes
|
|
58
|
+
if "\x00" in path:
|
|
59
|
+
raise ValueError("path contains null bytes")
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
resolved = Path(path).resolve()
|
|
63
|
+
except (OSError, RuntimeError) as e:
|
|
64
|
+
raise ValueError(f"Invalid path: {e}")
|
|
65
|
+
|
|
66
|
+
# Check if within allowed directory
|
|
67
|
+
if allowed_dir:
|
|
68
|
+
try:
|
|
69
|
+
allowed = Path(allowed_dir).resolve()
|
|
70
|
+
resolved.relative_to(allowed)
|
|
71
|
+
except ValueError:
|
|
72
|
+
raise ValueError(f"path must be within {allowed_dir}")
|
|
73
|
+
|
|
74
|
+
# Check for dangerous system paths
|
|
75
|
+
dangerous_paths = ["/etc", "/sys", "/proc", "/dev"]
|
|
76
|
+
for dangerous in dangerous_paths:
|
|
77
|
+
if str(resolved).startswith(dangerous):
|
|
78
|
+
raise ValueError(f"Cannot write to system directory: {dangerous}")
|
|
79
|
+
|
|
80
|
+
return resolved
|
|
81
|
+
|
|
82
|
+
|
|
41
83
|
# Re-export for backward compatibility
|
|
42
84
|
__all__ = [
|
|
43
85
|
"DEFAULT_MODELS",
|
|
@@ -371,7 +413,10 @@ class WorkflowConfig:
|
|
|
371
413
|
|
|
372
414
|
def save(self, path: str | Path) -> None:
|
|
373
415
|
"""Save configuration to file."""
|
|
374
|
-
path
|
|
416
|
+
# Validate path first (convert Path to string for validation)
|
|
417
|
+
path_str = str(path)
|
|
418
|
+
validated_path = _validate_file_path(path_str)
|
|
419
|
+
|
|
375
420
|
data = {
|
|
376
421
|
"default_provider": self.default_provider,
|
|
377
422
|
"workflow_providers": self.workflow_providers,
|
|
@@ -387,15 +432,15 @@ class WorkflowConfig:
|
|
|
387
432
|
"audit_level": self.audit_level,
|
|
388
433
|
}
|
|
389
434
|
|
|
390
|
-
|
|
435
|
+
validated_path.parent.mkdir(parents=True, exist_ok=True)
|
|
391
436
|
|
|
392
|
-
if
|
|
437
|
+
if validated_path.suffix in (".yaml", ".yml"):
|
|
393
438
|
if not YAML_AVAILABLE:
|
|
394
439
|
raise ImportError("PyYAML required for YAML config")
|
|
395
|
-
with open(
|
|
440
|
+
with open(validated_path, "w") as f:
|
|
396
441
|
yaml.dump(data, f, default_flow_style=False)
|
|
397
442
|
else:
|
|
398
|
-
with open(
|
|
443
|
+
with open(validated_path, "w") as f:
|
|
399
444
|
json.dump(data, f, indent=2)
|
|
400
445
|
|
|
401
446
|
|