File indexing completed on 2026-07-21 08:46:24
0001
0002 """Evaluate one coherent Snapper capture opportunity for SWF scopes."""
0003
0004 import argparse
0005 import json
0006 import os
0007 import sys
0008 import time
0009 from datetime import datetime, timedelta
0010
0011 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0012 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0013 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0014
0015 import django
0016 django.setup()
0017
0018 from django.utils import timezone
0019
0020 from monitor_app.models import SysConfig
0021 from snapper_ai.capture import (
0022 aligned_boundary,
0023 capture_scope,
0024 report_capture_failure,
0025 )
0026 from snapper_ai.models import CaptureCursor
0027
0028
0029 SCOPES = ('testbed', 'epicprod')
0030 DEFAULT_OPPORTUNITY_SECONDS = 10
0031 DEFAULT_BASELINE_EVERY = 10
0032 DEFAULT_LOCK_TIMEOUT_MS = 5000
0033
0034
0035 def _boundary(value):
0036 if not value:
0037 return None
0038 normalized = value[:-1] + '+00:00' if value.endswith('Z') else value
0039 try:
0040 parsed = datetime.fromisoformat(normalized)
0041 except ValueError as exc:
0042 raise argparse.ArgumentTypeError(str(exc)) from exc
0043 if not timezone.is_aware(parsed):
0044 raise argparse.ArgumentTypeError('boundary must include a timezone')
0045 return parsed
0046
0047
0048 def _positive_setting(value, key, minimum=1):
0049 try:
0050 value = int(value)
0051 except (TypeError, ValueError) as exc:
0052 raise ValueError(f'{key} must be an integer, got {value!r}') from exc
0053 if value < minimum:
0054 raise ValueError(f'{key} must be at least {minimum}, got {value!r}')
0055 return value
0056
0057
0058 def _scope_config(scope, args):
0059 opportunity_key = f'snapper_opportunity_seconds_{scope}'
0060 baseline_key = f'snapper_baseline_every_{scope}'
0061 policy_key = f'snapper_capture_policy_{scope}'
0062 opportunity = (
0063 args.opportunity_seconds
0064 if args.opportunity_seconds is not None
0065 else SysConfig.get_setting(
0066 opportunity_key, DEFAULT_OPPORTUNITY_SECONDS)
0067 )
0068 baseline = (
0069 args.baseline_every
0070 if args.baseline_every is not None
0071 else SysConfig.get_setting(baseline_key, DEFAULT_BASELINE_EVERY)
0072 )
0073 policy = (
0074 args.capture_policy
0075 if args.capture_policy is not None
0076 else SysConfig.get_setting(policy_key, f'{scope}-v1')
0077 )
0078 lock_timeout = (
0079 args.lock_timeout_ms
0080 if args.lock_timeout_ms is not None
0081 else SysConfig.get_setting(
0082 'snapper_lock_timeout_ms', DEFAULT_LOCK_TIMEOUT_MS)
0083 )
0084 return {
0085 'opportunity_seconds': _positive_setting(
0086 opportunity, opportunity_key, minimum=10),
0087 'baseline_every': _positive_setting(baseline, baseline_key),
0088 'capture_policy': str(policy or '').strip(),
0089 'lock_timeout_ms': _positive_setting(
0090 lock_timeout, 'snapper_lock_timeout_ms'),
0091 }
0092
0093
0094 def _target_boundary(scope, args, opportunity_seconds):
0095 if args.boundary is not None:
0096 return args.boundary
0097 now = args.requested_at or timezone.now()
0098 boundary = aligned_boundary(now, opportunity_seconds)
0099 if not args.manual:
0100 return boundary
0101 latest = CaptureCursor.objects.filter(scope=scope).values_list(
0102 'latest_boundary_at', flat=True).first()
0103 if latest is not None and latest >= boundary:
0104 boundary += timedelta(seconds=opportunity_seconds)
0105 delay = (boundary - timezone.now()).total_seconds()
0106 if delay > 0:
0107 time.sleep(delay)
0108 return boundary
0109
0110
0111 def _result_payload(scope, result, config):
0112 return {
0113 'scope': scope,
0114 'boundary_at': result.boundary_at.isoformat(),
0115 'outcome': result.outcome,
0116 'reasons': list(result.reasons),
0117 'changed_components': list(result.changed_components),
0118 'coverage_gap_started_at': (
0119 result.coverage_gap_started_at.isoformat()
0120 if result.coverage_gap_started_at else None
0121 ),
0122 'snap_id': str(result.snap.pk) if result.snap else None,
0123 'state_hash': result.snap.state_hash if result.snap else None,
0124 'capture_policy': config['capture_policy'],
0125 'opportunity_seconds': config['opportunity_seconds'],
0126 'baseline_every': config['baseline_every'],
0127 }
0128
0129
0130 def main(argv):
0131 ap = argparse.ArgumentParser(
0132 description='Evaluate one SWF Snapper capture opportunity.')
0133 ap.add_argument('--scope', choices=(*SCOPES, 'all'), default='all')
0134 ap.add_argument('--manual', action='store_true',
0135 help='Force a full snap at this boundary.')
0136 ap.add_argument('--boundary', type=_boundary,
0137 help='Aligned ISO timestamp; current boundary by default.')
0138 ap.add_argument('--requested-at', type=_boundary,
0139 help='Scheduler invocation time used to select the boundary.')
0140 ap.add_argument('--opportunity-seconds', type=int,
0141 help='Override the scope SysConfig value.')
0142 ap.add_argument('--baseline-every', type=int,
0143 help='Override the scope SysConfig value.')
0144 ap.add_argument('--capture-policy',
0145 help='Override the scope SysConfig policy identifier.')
0146 ap.add_argument('--lock-timeout-ms', type=int,
0147 help='Override the shared SysConfig lock timeout.')
0148 args = ap.parse_args(argv[1:])
0149
0150 scopes = SCOPES if args.scope == 'all' else (args.scope,)
0151 reports = []
0152 failed = False
0153 for scope in scopes:
0154 boundary = None
0155 config = None
0156 try:
0157 config = _scope_config(scope, args)
0158 if not config['capture_policy']:
0159 raise ValueError(
0160 f'snapper_capture_policy_{scope} must not be blank')
0161 boundary = _target_boundary(
0162 scope, args, config['opportunity_seconds'])
0163 result = capture_scope(
0164 scope=scope,
0165 boundary_at=boundary,
0166 capture_policy=config['capture_policy'],
0167 opportunity_seconds=config['opportunity_seconds'],
0168 baseline_every=config['baseline_every'],
0169 manual=args.manual,
0170 lock_timeout_ms=config['lock_timeout_ms'],
0171 )
0172 except Exception as exc:
0173 failed = True
0174 report = {
0175 'scope': scope,
0176 'boundary_at': boundary.isoformat() if boundary else None,
0177 'outcome': 'failed',
0178 'error': str(exc),
0179 }
0180 if boundary is not None:
0181 try:
0182 report_capture_failure(
0183 scope=scope,
0184 boundary_at=boundary,
0185 error=str(exc),
0186 )
0187 except Exception as report_exc:
0188 report['failure_report_error'] = str(report_exc)
0189 if config is not None:
0190 report.update({
0191 'capture_policy': config['capture_policy'],
0192 'opportunity_seconds': config['opportunity_seconds'],
0193 'baseline_every': config['baseline_every'],
0194 })
0195 reports.append(report)
0196 else:
0197 reports.append(_result_payload(scope, result, config))
0198
0199 print(json.dumps(reports, indent=2, sort_keys=True))
0200 return 1 if failed else 0
0201
0202
0203 if __name__ == '__main__':
0204 sys.exit(main(sys.argv))