1313import sqlite3
1414
1515from bob_overview import snapshot as overview_snapshot
16- from bob_publish_common import DB
16+ from bob_publish_common import DB , manual_recovery_allowed
1717
1818SOURCE_LANES = ("publisher" ,"scout" ,"original" ,"dependencies" )
1919RUN = re .compile (r"\d{8}T\d{6}Z\Z" )
3737MAX_JOBS = 150
3838MAX_EVENTS = 80
3939MAX_BLOCKS = 3
40+ MAX_HANDOFF_CHECKS = 25
41+ MAX_LINKS = 3
4042CSS = """<style>
4143.bi{max-width:1090px;margin:auto;display:grid;gap:12px}
4244.bi-panel{background:linear-gradient(145deg,#162b43,#0e1b2e);border:1px solid #365575;
@@ -78,7 +80,7 @@ def _date(s):
7880
7981def _source (when ):
8082 stamp = _date (when )
81- return stamp .isoformat (timespec = "seconds" ) if stamp else "Time unavailable "
83+ return stamp .isoformat (timespec = "seconds" ) if stamp else "Policy / historical summary "
8284
8385def _reason (code ,title ,detail ,source = "" ,href = "/logs" ):
8486 return dict (code = code ,title = title ,detail = detail ,source = _source (source ),href = href )
@@ -89,7 +91,10 @@ def _blank():
8991 "reviewed" :{"approved" :0 ,"rejected" :0 ,"unknown" :0 },
9092 "queue" :{"manual" :0 ,"auto" :0 },
9193 "approved_blocked" :0 ,"rejected_blocked" :0 ,"undecided_blocked" :0 ,
92- "sent_or_uncertain" :0 ,"oldest_manual" :"" ,"oldest_auto" :"" }
94+ "sent_or_uncertain" :0 ,"oldest_manual" :"" ,"oldest_auto" :"" ,
95+ "job_window" :{"oldest" :"" ,"newest" :"" ,"inspected" :0 },
96+ "latest_review_block" :{"approved" :"" ,"rejected" :"" ,"unknown" :"" },
97+ "manual_handoff" :{"checked" :0 ,"verified" :0 ,"run_ids" :[]}}
9398
9499def read_private (path = DB ,* ,at = None ):
95100 """Read-only bounded evidence. Never select arbitrary error or draft text."""
@@ -111,11 +116,20 @@ def read_private(path=DB,*,at=None):
111116 "FROM publish_jobs j LEFT JOIN decisions d ON d.run_id=j.run_id "
112117 "ORDER BY j.updated_at DESC LIMIT ?" ,(MAX_JOBS ,)).fetchall ()
113118 out ["available" ]= True
119+ out ["job_window" ]["inspected" ]= len (rows )
114120 for (rid ,mode ,state ,error ,created ,updated ,attempted ,comment ,digest ,
115121 challenge_code ,challenge_text ,challenge_expiry ,verify_answer ,
116122 decision ,decision_hash ) in rows :
117123 if mode not in ("auto" ,"manual" ) or not isinstance (state ,str ):
118124 continue
125+ updated_time = _date (updated )
126+ if updated_time :
127+ period = out ["job_window" ]
128+ for field ,earliest in (("oldest" ,True ),("newest" ,False )):
129+ previous = _date (period [field ])
130+ if previous is None or (updated_time < previous if earliest
131+ else updated_time > previous ):
132+ period [field ]= updated_time .isoformat (timespec = "seconds" )
119133 if state == "queued" :
120134 out ["queue" ][mode ]+= 1
121135 key = "oldest_" + mode
@@ -141,12 +155,31 @@ def read_private(path=DB,*,at=None):
141155 attempted ,comment ,challenge_code ,challenge_text ,
142156 challenge_expiry ,verify_answer ))
143157 if mode == "auto" and state == "failed" and error in REVIEW_BLOCK and untouched :
144- if decision == "approved" and decision_hash == digest :
158+ classification = ("approved" if decision == "approved" and decision_hash == digest
159+ else "rejected" if decision == "rejected" and decision_hash == digest
160+ else "unknown" )
161+ if classification == "approved" :
145162 out ["approved_blocked" ]+= 1
146- elif decision == "rejected" and decision_hash == digest :
163+ # No unchecked assumption that a historic DB row is
164+ # still source-verified or actionable. The same vetted
165+ # helper backs the existing owner Publish button.
166+ handoff = out ["manual_handoff" ]
167+ if handoff ["checked" ]< MAX_HANDOFF_CHECKS and RUN .fullmatch (str (rid )):
168+ handoff ["checked" ]+= 1
169+ try :
170+ if manual_recovery_allowed (db ,rid ):
171+ handoff ["verified" ]+= 1
172+ if len (handoff ["run_ids" ])< MAX_LINKS :
173+ handoff ["run_ids" ].append (rid )
174+ except (sqlite3 .Error ,OSError ,TypeError ,ValueError ):
175+ pass
176+ elif classification == "rejected" :
147177 out ["rejected_blocked" ]+= 1
148178 else :
149179 out ["undecided_blocked" ]+= 1
180+ prior = _date (out ["latest_review_block" ][classification ])
181+ if updated_time and (prior is None or updated_time > prior ):
182+ out ["latest_review_block" ][classification ]= updated_time .isoformat (timespec = "seconds" )
150183 if mode == "auto" and state == "queued" and decision in ("approved" ,"rejected" ) and decision_hash == digest :
151184 out ["reviewed" ][decision ]+= 1
152185 events = db .execute (
@@ -238,17 +271,25 @@ def section(key,title,lane,summary,items,href):
238271 no = history .get ("rejected_blocked" ,0 )
239272 unknown = history .get ("undecided_blocked" ,0 )
240273 if yes :
241- reasons .append (_reason ("AUTO_APPROVED_HANDOFF" ,f"{ yes } approved auto draft(s) blocked" ,
242- "Automatic sending stopped before a recorded attempt. If the verified draft is still eligible, explicitly queue it manually; approval alone does not send." ,
243- "" , "/scout" ))
274+ handoff = history .get ("manual_handoff" ) or {}
275+ checked = handoff .get ("checked" ,0 )
276+ verified = handoff .get ("verified" ,0 )
277+ reasons .append (_reason ("AUTO_APPROVED_HANDOFF" ,
278+ f"{ yes } historical approved auto job(s) blocked" ,
279+ f"{ verified } of { checked } inspected against current saved sources "
280+ "have a verified owner-controlled handoff; historical approval "
281+ "alone is not a new publication authorization. Check Scout "
282+ "for the explicit manual queue action; age, rate and duplicate "
283+ "checks still apply." ,
284+ history .get ("latest_review_block" ,{}).get ("approved" ,"" ),"/scout" ))
244285 if no :
245286 reasons .append (_reason ("AUTO_REJECTED" ,f"{ no } rejected auto draft(s) blocked" ,
246287 "Your rejection was respected. These are not awaiting approval and must not publish." ,
247- "" , "/scout" ))
288+ history . get ( "latest_review_block" ,{}). get ( "rejected" , "" ) , "/scout" ))
248289 if unknown :
249290 reasons .append (_reason ("REVIEW_UNCONFIRMED" ,f"{ unknown } reviewed job(s) need checking" ,
250291 "The saved job reports a review block, but the stored decision is missing or does not match the draft." ,
251- "" , "/scout" ))
292+ history . get ( "latest_review_block" ,{}). get ( "unknown" , "" ) , "/scout" ))
252293 if history ["reviewed" ].get ("approved" ):
253294 reasons .append (_reason ("QUEUED_AUTO_APPROVED" ,"Approved auto queue needs manual review" ,
254295 "An automatic queued job has an approval; automatic posting must not bypass it." ,
@@ -260,7 +301,13 @@ def section(key,title,lane,summary,items,href):
260301 if not reasons :
261302 reasons .append (_reason ("AUTO_UNKNOWN" ,"Automatic activity not established" ,
262303 "Check Scout selections, review decisions and publisher events." ,"" ,"/scout" ))
263- auto_summary = ("Auto queue: " + str (auto ) if auto is not None else "Auto queue not reported" )
304+ window = history .get ("job_window" ) or {}
305+ span = (" · history " + str (window .get ("oldest" ))[:10 ]+ " to " +
306+ str (window .get ("newest" ))[:10 ]+ " UTC" if window .get ("oldest" )
307+ and window .get ("newest" ) else " · history range unavailable" )
308+ auto_summary = (("Auto queue: " + str (auto ) if auto is not None
309+ else "Auto queue not reported" )+
310+ span + " · up to " + str (MAX_JOBS )+ " recent jobs inspected" )
264311 lines .append (section ("auto" ,"Automatic comments" ,
265312 publisher ,auto_summary ,reasons ,"/automation" ))
266313 # Original posting is explicitly owner requested.
@@ -307,6 +354,11 @@ def section(key,title,lane,summary,items,href):
307354 "last_skip" :history .get ("last_skip" ),
308355 "policy_next" :(metrics .get ("next_possible_utc" ) or {}).get ("comments" ) or "" ,
309356 "scheduled_next" :publisher .get ("next_scheduled" ) or "" ,
357+ "scout_checkpoint" :scout .get ("updated_at" ) or "" ,
358+ "scout_activity" :scout .get ("activity" ) or "Not confirmed" ,
359+ "handoff_links" :(history .get ("manual_handoff" ) or {}).get ("run_ids" ,[])[:MAX_LINKS ],
360+ "handoff_verified" :(history .get ("manual_handoff" ) or {}).get ("verified" ,0 ),
361+ "publisher_last_skip" :history .get ("last_skip" ),
310362 "notice" :"Reason codes are deterministic local observations, not a model's guess. "
311363 "A saved approval does not mean a comment was sent." }
312364
@@ -324,13 +376,22 @@ def page(wrap,*,data=None):
324376 '<a href="/investigate">Refresh evidence</a></div>' ,
325377 '<p class="bi-muted">Checked ' + e (item ["checked_at" ])+
326378 ' · no actions or retries are performed here.</p></section>' ,
327- '<section class="bi-panel"><h2>Publication timing</h2>' ,
379+ '<section class="bi-panel"><h2>Last activity & timing</h2>' ,
380+ '<p class="bi-line">Most recent Scout checkpoint: <b>' +
381+ e (item .get ("scout_checkpoint" ) or "Not recorded" )+ '</b> · ' +
382+ e (item .get ("scout_activity" ) or "Status unconfirmed" )+ '</p>' ,
383+ '<p class="bi-line">Last publisher evaluation: <b>' +
384+ e ((item .get ("publisher_last_skip" ) or {}).get ("at" ) or
385+ "No recent safe skip record" )+ '</b> · ' +
386+ e ((item .get ("publisher_last_skip" ) or {}).get ("code" ) or
387+ "No recorded skip" )+ '</p>' ,
328388 '<p class="bi-line">Last recorded attempt: <b>' +
329389 e (item ["last_attempt" ] or "Not recorded" )+ '</b></p>' ,
330390 '<p class="bi-line">Last API-acknowledged publication: <b>' +
331391 e (item ["last_success" ] or "Not recorded in recent inspected jobs" )+ '</b></p>' ,
332392 '<p class="bi-line">Next systemd publisher trigger: <b>' +
333- e (item ["scheduled_next" ] or "Not reported" )+ '</b></p>' ,
393+ e (item ["scheduled_next" ] or
394+ "Not reported by systemd; check Automation / timer state" )+ '</b></p>' ,
334395 '<p class="bi-line">Earliest policy-permitted comment: <b>' +
335396 e (item ["policy_next" ] or "Not reported" )+ '</b> (not scheduled)</p>' ,
336397 '<p class="bi-muted">API acknowledgement is not independent proof that '
@@ -350,5 +411,16 @@ def page(wrap,*,data=None):
350411 ' · ' + e (reason ["source" ])+ '</span></div>' ]
351412 parts += ['</div><p class="bi-line"><a href="' + e (section ["href" ])+
352413 '">Inspect supporting records</a></p></section>' ]
414+ if item .get ("handoff_links" ):
415+ parts += ['<section class="bi-panel"><h2>Source-verified manual handoffs</h2>' ,
416+ '<p class="bi-muted">These historical jobs still pass the '
417+ 'read-only saved-source check. Links open owner review; '
418+ 'nothing is sent or queued from this page. '
419+ 'The publisher will still enforce age, rate and duplicate safety checks.</p>' ,
420+ '<div class="bi-links">' ]
421+ for rid in item ["handoff_links" ][:MAX_LINKS ]:
422+ if RUN .fullmatch (str (rid )):
423+ parts .append ('<a href="/scout#run-' + e (rid )+ '">Review ' + e (rid )+ '</a>' )
424+ parts .append ('</div></section>' )
353425 parts += ['</div></div>' ]
354426 return wrap ("Why no activity?" ,"" .join (parts ),"/investigate" )
0 commit comments