File indexing completed on 2026-08-12 09:36:15
0001 """
0002 Snapper temporal-query MCP tools — thin wrappers over snapper_ai.queries.
0003
0004 Snapper records immutable, coherent snapshots ("snaps") of system-wide
0005 state at aligned opportunities, turning "what did the system look like
0006 at time T" from an inference problem into a retrieval problem. These
0007 tools return the typed evidence envelope unchanged; they never infer
0008 continuity that was not observed.
0009 """
0010
0011 from django.utils.dateparse import parse_datetime
0012
0013 from asgiref.sync import sync_to_async
0014
0015 from monitor_app.mcp import mcp
0016 from snapper_ai import queries
0017
0018
0019 def _time(raw, label):
0020 value = parse_datetime(str(raw or '').strip())
0021 if value is None or value.tzinfo is None:
0022 raise queries.InvalidQuery(
0023 f"{label} must be ISO 8601 with timezone, "
0024 f"e.g. '2026-07-23T04:00:00Z'")
0025 return value
0026
0027
0028 def _call(fn):
0029 try:
0030 return fn().as_dict()
0031 except queries.SnapperError as e:
0032 return {'error': str(e)}
0033
0034
0035 @mcp.tool()
0036 async def snapper_latest(scope: str) -> dict:
0037 """
0038 Latest recorded system state for one Snapper scope.
0039
0040 Use this to know what the whole system looked like at the most recent
0041 coherent capture — component states with their assessment times, not
0042 live probes.
0043
0044 Args:
0045 scope: 'epicprod' (PanDA/production) or 'testbed'.
0046
0047 Reading the result (shared by all snapper_* tools):
0048 state: the recorded component state documents, each with its own
0049 assessment time and, when applicable, source time.
0050 snap_time: when the returned state was ACTUALLY captured — always
0051 report this actual time.
0052 coverage: 'covered' means the observer was recording; 'gap' means
0053 a known observation gap (state across it is unknown);
0054 'unknown' means the evidence cannot say. Never treat gap or
0055 unknown intervals as if the last state persisted through them.
0056 Schema/policy versions, hashes, and provenance identify how to
0057 interpret each snap; old snaps keep the shape that was true
0058 when captured.
0059 """
0060 return await sync_to_async(_call)(lambda: queries.latest(scope))
0061
0062
0063 @mcp.tool()
0064 async def snapper_state_at(scope: str, time: str) -> dict:
0065 """
0066 Recorded system state at (or last before) a past instant.
0067
0068 Use this for questions like "what was running when the incident
0069 began?". The answer is the latest snap at or before the requested
0070 time, returned with its ACTUAL snap time and honest coverage — it
0071 does not pretend the state was observed at the requested instant.
0072
0073 Args:
0074 scope: 'epicprod' (PanDA/production) or 'testbed'.
0075 time: the past instant, ISO 8601 with timezone, e.g.
0076 '2026-07-22T14:30:00Z'.
0077
0078 Reading the result: state documents plus snap_time (the actual
0079 capture time, possibly earlier than requested) and coverage —
0080 'covered', 'gap' (known observation gap; state across it is
0081 unknown), or 'unknown'. Never present state across a gap or unknown
0082 interval as observed fact.
0083 """
0084 return await sync_to_async(_call)(
0085 lambda: queries.state_at(scope, _time(time, 'time')))
0086
0087
0088 @mcp.tool()
0089 async def snapper_component_history(
0090 scope: str,
0091 component: str,
0092 start: str,
0093 end: str,
0094 include_unchanged: bool = False,
0095 ) -> dict:
0096 """
0097 One component's evolution over an interval.
0098
0099 The first entry is the component's state at the interval start (with
0100 its actual snap time); subsequent entries are recorded changes.
0101 Absence and appearance are explicit; recovery evidence is never
0102 suppressed.
0103
0104 Args:
0105 scope: 'epicprod' (PanDA/production) or 'testbed'.
0106 component: registered component name — 'health' (either scope),
0107 'datataking' (testbed), or 'panda' (epicprod).
0108 start: interval start, ISO 8601 with timezone.
0109 end: interval end, ISO 8601 with timezone.
0110 include_unchanged: also return semantically unchanged baseline
0111 entries (default False).
0112
0113 Reading the result: entries carry actual snap times, content hashes,
0114 revisions, and schema versions; coverage is reported at both
0115 requested endpoints ('covered', 'gap', or 'unknown'). Never treat a
0116 gap or unknown interval as continuity of the last recorded value.
0117 """
0118 return await sync_to_async(_call)(
0119 lambda: queries.component_history(
0120 scope, component, _time(start, 'start'), _time(end, 'end'),
0121 suppress_unchanged_baselines=not include_unchanged))
0122
0123
0124 @mcp.tool()
0125 async def snapper_context_around(scope: str, time: str,
0126 window_seconds: float = 3600) -> dict:
0127 """
0128 Full temporal context at an instant: coherent state, nearby changes,
0129 and resolvable references to the exact event streams.
0130
0131 Use this first when investigating an incident time: it returns the
0132 recorded system state at the instant, every component change in the
0133 window around it, and for each component a reference naming the
0134 authoritative service (REST URL and MCP tools in the reference's
0135 transport field) that holds the exact transitions — drill down
0136 there for event-level truth.
0137
0138 Args:
0139 scope: 'epicprod' (PanDA/production) or 'testbed'.
0140 time: the instant, ISO 8601 with timezone.
0141 window_seconds: window centered on the instant (default 3600).
0142
0143 Reading the result: state carries its ACTUAL snap time and coverage
0144 ('covered', 'gap', 'unknown' — never infer continuity across gap or
0145 unknown intervals); references carry availability and a transport
0146 with rest_url, rest_params, and mcp_tools naming exactly how to
0147 fetch the underlying events.
0148 """
0149 from monitor_app.snapper_resolvers import annotate_references
0150
0151 def call():
0152 result = queries.context_around(
0153 scope, _time(time, 'time'), window_seconds).as_dict()
0154 result['references'] = annotate_references(result['references'])
0155 return result
0156
0157 def guarded():
0158 try:
0159 return call()
0160 except queries.SnapperError as e:
0161 return {'error': str(e)}
0162
0163 return await sync_to_async(guarded)()
0164
0165
0166 @mcp.tool()
0167 async def snapper_changes_between(scope: str, start: str, end: str) -> dict:
0168 """
0169 What changed across the whole system between two moments.
0170
0171 Every component difference is classified added, changed, or removed,
0172 with previous and current documents, hashes, and versions.
0173 Value-identical baselines are omitted; recovery and capture-policy
0174 transitions remain as evidence.
0175
0176 Args:
0177 scope: 'epicprod' (PanDA/production) or 'testbed'.
0178 start: comparison boundary, ISO 8601 with timezone.
0179 end: interval end, ISO 8601 with timezone.
0180
0181 Reading the result: the comparison boundary snap and its actual time
0182 are returned with the changes; coverage is reported at both
0183 requested endpoints ('covered', 'gap', or 'unknown'). Never treat a
0184 gap or unknown interval as if nothing changed within it.
0185
0186 Counting job outcomes over an interval: the epicprod panda
0187 component carries monotonic cumulative terminal-job counters —
0188 jobs.cum (finished, failed, cancelled, closed) and, per site,
0189 jobs.sites.<site>.cum plus jobs.sites.<site>.cum_failed_by_class
0190 (error component classes). Subtract the counter at start from the
0191 counter at end to count that interval's outcomes, e.g. how many
0192 jobs finished and failed at one site during a production test and
0193 which failure classes dominated.
0194 """
0195 return await sync_to_async(_call)(
0196 lambda: queries.changes_between(
0197 scope, _time(start, 'start'), _time(end, 'end')))