@@ -17,6 +17,27 @@ class FailureArtifactsOptions:
1717 fps : float = 0.0
1818 persist_mode : Literal ["onFail" , "always" ] = "onFail"
1919 output_dir : str = ".sentience/artifacts"
20+ on_before_persist : Callable [[RedactionContext ], RedactionResult ] | None = None
21+ redact_snapshot_values : bool = True
22+
23+
24+ @dataclass
25+ class RedactionContext :
26+ run_id : str
27+ reason : str | None
28+ status : Literal ["failure" , "success" ]
29+ snapshot : Any | None
30+ diagnostics : Any | None
31+ frame_paths : list [str ]
32+ metadata : dict [str , Any ]
33+
34+
35+ @dataclass
36+ class RedactionResult :
37+ snapshot : Any | None = None
38+ diagnostics : Any | None = None
39+ frame_paths : list [str ] | None = None
40+ drop_frames : bool = False
2041
2142
2243@dataclass
@@ -99,6 +120,27 @@ def _write_json_atomic(self, path: Path, data: Any) -> None:
99120 tmp_path .write_text (json .dumps (data , indent = 2 ))
100121 tmp_path .replace (path )
101122
123+ def _redact_snapshot_defaults (self , payload : Any ) -> Any :
124+ if not isinstance (payload , dict ):
125+ return payload
126+ elements = payload .get ("elements" )
127+ if not isinstance (elements , list ):
128+ return payload
129+ redacted = []
130+ for el in elements :
131+ if not isinstance (el , dict ):
132+ redacted .append (el )
133+ continue
134+ input_type = (el .get ("input_type" ) or "" ).lower ()
135+ if input_type in {"password" , "email" , "tel" } and "value" in el :
136+ el = dict (el )
137+ el ["value" ] = None
138+ el ["value_redacted" ] = True
139+ redacted .append (el )
140+ payload = dict (payload )
141+ payload ["elements" ] = redacted
142+ return payload
143+
102144 def persist (
103145 self ,
104146 * ,
@@ -118,25 +160,59 @@ def persist(
118160 frames_out = run_dir / "frames"
119161 frames_out .mkdir (parents = True , exist_ok = True )
120162
121- for frame in self ._frames :
122- shutil .copy2 (frame .path , frames_out / frame .file_name )
123-
124- self ._write_json_atomic (run_dir / "steps.json" , self ._steps )
125-
126163 snapshot_payload = None
127164 if snapshot is not None :
128165 if hasattr (snapshot , "model_dump" ):
129166 snapshot_payload = snapshot .model_dump ()
130167 else :
131168 snapshot_payload = snapshot
132- self ._write_json_atomic (run_dir / "snapshot.json" , snapshot_payload )
169+ if self .options .redact_snapshot_values :
170+ snapshot_payload = self ._redact_snapshot_defaults (snapshot_payload )
133171
134172 diagnostics_payload = None
135173 if diagnostics is not None :
136174 if hasattr (diagnostics , "model_dump" ):
137175 diagnostics_payload = diagnostics .model_dump ()
138176 else :
139177 diagnostics_payload = diagnostics
178+
179+ frame_paths = [str (frame .path ) for frame in self ._frames ]
180+ drop_frames = False
181+
182+ if self .options .on_before_persist is not None :
183+ try :
184+ result = self .options .on_before_persist (
185+ RedactionContext (
186+ run_id = self .run_id ,
187+ reason = reason ,
188+ status = status ,
189+ snapshot = snapshot_payload ,
190+ diagnostics = diagnostics_payload ,
191+ frame_paths = frame_paths ,
192+ metadata = metadata or {},
193+ )
194+ )
195+ if result .snapshot is not None :
196+ snapshot_payload = result .snapshot
197+ if result .diagnostics is not None :
198+ diagnostics_payload = result .diagnostics
199+ if result .frame_paths is not None :
200+ frame_paths = result .frame_paths
201+ drop_frames = result .drop_frames
202+ except Exception :
203+ drop_frames = True
204+
205+ if not drop_frames :
206+ for frame_path in frame_paths :
207+ src = Path (frame_path )
208+ if not src .exists ():
209+ continue
210+ shutil .copy2 (src , frames_out / src .name )
211+
212+ self ._write_json_atomic (run_dir / "steps.json" , self ._steps )
213+ if snapshot_payload is not None :
214+ self ._write_json_atomic (run_dir / "snapshot.json" , snapshot_payload )
215+ if diagnostics_payload is not None :
140216 self ._write_json_atomic (run_dir / "diagnostics.json" , diagnostics_payload )
141217
142218 manifest = {
@@ -145,11 +221,15 @@ def persist(
145221 "status" : status ,
146222 "reason" : reason ,
147223 "buffer_seconds" : self .options .buffer_seconds ,
148- "frame_count" : len (self ._frames ),
149- "frames" : [{"file" : frame .file_name , "ts" : frame .ts } for frame in self ._frames ],
224+ "frame_count" : 0 if drop_frames else len (frame_paths ),
225+ "frames" : (
226+ [] if drop_frames else [{"file" : Path (p ).name , "ts" : None } for p in frame_paths ]
227+ ),
150228 "snapshot" : "snapshot.json" if snapshot_payload is not None else None ,
151229 "diagnostics" : "diagnostics.json" if diagnostics_payload is not None else None ,
152230 "metadata" : metadata or {},
231+ "frames_redacted" : not drop_frames and self .options .on_before_persist is not None ,
232+ "frames_dropped" : drop_frames ,
153233 }
154234 self ._write_json_atomic (run_dir / "manifest.json" , manifest )
155235
0 commit comments