RFTSystems commited on
Commit
b7e16d0
·
verified ·
1 Parent(s): 6dc3add

Update rft_flightrecorder.py

Browse files
Files changed (1) hide show
  1. rft_flightrecorder.py +190 -91
rft_flightrecorder.py CHANGED
@@ -6,6 +6,8 @@ import zipfile
6
  from datetime import datetime, timezone
7
  from typing import Any, Dict, List, Optional, Tuple
8
 
 
 
9
  from cryptography.hazmat.primitives.asymmetric.ed25519 import (
10
  Ed25519PrivateKey,
11
  Ed25519PublicKey,
@@ -17,6 +19,8 @@ EVENT_SPEC = "rft-flight-event-v0"
17
  ROOT_SPEC = "rft-flight-session-root-v0"
18
  DEFAULT_LOG_PATH = "flightlog.jsonl"
19
 
 
 
20
 
21
  # ============================================================
22
  # Canonical JSON + hashing
@@ -45,6 +49,20 @@ def short(h: str, n: int = 12) -> str:
45
  return h[:n] if len(h) > n else h
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  # ============================================================
49
  # Key management (Ed25519)
50
  # ============================================================
@@ -196,49 +214,59 @@ def append_event(
196
  if not sid:
197
  return None, "Missing session_id."
198
 
199
- all_events, _corrupt = read_jsonl(log_path)
 
 
 
 
200
 
201
- payload = parse_payload_text(payload_text)
202
- meta = {
203
- "model_id": (model_id or "unknown").strip(),
204
- "run_mode": (run_mode or "unknown").strip(),
205
- }
206
 
207
- last = last_event_for_session(all_events, sid)
208
- prev_hash = last.get("event_hash_sha256") if last else ("0" * 64)
209
- seq = next_seq(all_events, sid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
 
211
- # If parent hash not provided, default to prev hash (simple linear causality)
212
- parent = (parent_event_hash or "").strip()
213
- if not parent and seq > 1:
214
- parent = prev_hash
215
 
216
- core = make_event_core(
217
- session_id=sid,
218
- seq=seq,
219
- event_type=event_type,
220
- payload=payload,
221
- parent_event_hash=parent,
222
- prev_event_hash=prev_hash,
223
- meta=meta,
224
- )
225
 
226
- event_hash = sha256_hex(canon(core))
227
- event = dict(core)
228
- event["event_hash_sha256"] = event_hash
229
 
230
- if sign_event:
231
- if not sk_hex or not sk_hex.strip():
232
- return None, "Signing enabled but private key is missing."
233
- try:
234
- sk = load_sk(sk_hex)
235
- sig = sk.sign(bytes.fromhex(event_hash))
236
- event["signature_ed25519"] = sig.hex()
237
- except Exception:
238
- return None, "Failed to sign event (invalid private key?)."
239
 
240
- append_jsonl(log_path, event)
241
- return event, f"OK. Appended event #{seq} ({event_type}). Hash: {event_hash}"
 
 
242
 
243
 
244
  def start_session(
@@ -280,56 +308,91 @@ def finalise_session(
280
  if not sid:
281
  return None, "Missing session_id."
282
 
283
- all_events, _corrupt = read_jsonl(log_path)
284
- evs = events_for_session(all_events, sid)
285
- if not evs:
286
- return None, "No events found for this session."
287
-
288
- first_hash = evs[0]["event_hash_sha256"]
289
- last_hash = evs[-1]["event_hash_sha256"]
290
- count = len(evs)
291
-
292
- root_core = {
293
- "spec": ROOT_SPEC,
294
- "session_id": sid,
295
- "first_event_hash_sha256": first_hash,
296
- "last_event_hash_sha256": last_hash,
297
- "event_count": count,
298
- }
299
- root_hash = sha256_hex(canon(root_core))
300
 
301
- anchor = dict(root_core)
302
- anchor["root_hash_sha256"] = root_hash
303
- anchor["created_utc"] = now_utc_iso()
304
- anchor["model_id"] = (model_id or "unknown").strip()
305
- anchor["run_mode"] = (run_mode or "unknown").strip()
306
 
307
- if sign_anchor:
308
- if not sk_hex or not sk_hex.strip():
309
- return None, "Anchor signing enabled but private key is missing."
310
- try:
311
- sk = load_sk(sk_hex)
312
- sig = sk.sign(bytes.fromhex(root_hash))
313
- anchor["signature_ed25519"] = sig.hex()
314
- except Exception:
315
- return None, "Failed to sign anchor (invalid private key?)."
316
 
317
- payload_text = json.dumps({"anchor": anchor}, ensure_ascii=False)
318
- ev, msg = append_event(
319
- log_path=log_path,
320
- session_id=sid,
321
- event_type="session_end",
322
- payload_text=payload_text,
323
- parent_event_hash=last_hash,
324
- sign_event=sign_anchor,
325
- sk_hex=sk_hex,
326
- model_id=model_id,
327
- run_mode=run_mode,
328
- )
329
- if not ev:
330
- return None, msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
- return anchor, f"OK. Session finalised. Root hash: {root_hash} (last event hash: {ev['event_hash_sha256']})"
 
 
 
 
 
333
 
334
 
335
  # ============================================================
@@ -451,9 +514,11 @@ def verify_session_from_events(
451
 
452
 
453
  def verify_session(log_path: str, session_id: str, pk_hex: str, require_signatures: bool) -> Tuple[str, bool, str]:
454
- all_events, _corrupt = read_jsonl(log_path)
 
 
 
455
  evs = events_for_session(all_events, session_id)
456
- # Ensure correct order even if file was rearranged
457
  evs.sort(key=lambda x: int(x.get("seq", 0)))
458
  return verify_session_from_events(evs, session_id, pk_hex=pk_hex, require_signatures=require_signatures)
459
 
@@ -467,7 +532,11 @@ def session_timeline_rows(log_path: str, session_id: str) -> Tuple[List[List[Any
467
  if not sid:
468
  return [], "Missing session_id."
469
 
470
- all_events, _corrupt = read_jsonl(log_path)
 
 
 
 
471
  evs = events_for_session(all_events, sid)
472
  if not evs:
473
  return [], "No events found."
@@ -496,7 +565,11 @@ def get_event_by_hash(log_path: str, session_id: str, event_hash: str) -> Tuple[
496
  if not sid or not h:
497
  return None, "Missing session_id or event hash."
498
 
499
- all_events, _corrupt = read_jsonl(log_path)
 
 
 
 
500
  evs = events_for_session(all_events, sid)
501
  for e in evs:
502
  if e.get("event_hash_sha256") == h:
@@ -505,7 +578,11 @@ def get_event_by_hash(log_path: str, session_id: str, event_hash: str) -> Tuple[
505
 
506
 
507
  def list_sessions(log_path: str) -> Tuple[List[str], str]:
508
- all_events, corrupt = read_jsonl(log_path)
 
 
 
 
509
  counts: Dict[str, int] = {}
510
  for e in all_events:
511
  if isinstance(e, dict) and e.get("spec") == EVENT_SPEC:
@@ -518,7 +595,20 @@ def list_sessions(log_path: str) -> Tuple[List[str], str]:
518
 
519
 
520
  def diagnostics(log_path: str) -> Dict[str, Any]:
521
- all_events, corrupt = read_jsonl(log_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  size = os.path.getsize(log_path) if os.path.exists(log_path) else 0
523
  sessions = set()
524
  signed = 0
@@ -550,7 +640,11 @@ def export_session_bundle(log_path: str, session_id: str) -> Tuple[Optional[str]
550
  if not sid:
551
  return None, "Missing session_id."
552
 
553
- all_events, _corrupt = read_jsonl(log_path)
 
 
 
 
554
  evs = events_for_session(all_events, sid)
555
  if not evs:
556
  return None, "No events found."
@@ -650,8 +744,13 @@ def import_bundle_verify(
650
  )
651
 
652
  if store_into_log and ok:
653
- for e in evs:
654
- append_jsonl(log_path, e)
 
 
 
 
 
655
 
656
  stored_msg = "Stored into local flightlog.jsonl." if (store_into_log and ok) else None
657
  return status, ok, report, stored_msg
 
6
  from datetime import datetime, timezone
7
  from typing import Any, Dict, List, Optional, Tuple
8
 
9
+ from filelock import FileLock, Timeout
10
+
11
  from cryptography.hazmat.primitives.asymmetric.ed25519 import (
12
  Ed25519PrivateKey,
13
  Ed25519PublicKey,
 
19
  ROOT_SPEC = "rft-flight-session-root-v0"
20
  DEFAULT_LOG_PATH = "flightlog.jsonl"
21
 
22
+ LOCK_TIMEOUT_S = 10 # keep small; we only lock for quick file IO
23
+
24
 
25
  # ============================================================
26
  # Canonical JSON + hashing
 
49
  return h[:n] if len(h) > n else h
50
 
51
 
52
+ # ============================================================
53
+ # File locking helpers
54
+ # ============================================================
55
+
56
+ def _lock_for(path: str) -> FileLock:
57
+ # one lock file per jsonl
58
+ return FileLock(path + ".lock", timeout=LOCK_TIMEOUT_S)
59
+
60
+
61
+ def _read_jsonl_locked(path: str) -> Tuple[List[Dict[str, Any]], int]:
62
+ with _lock_for(path):
63
+ return read_jsonl(path)
64
+
65
+
66
  # ============================================================
67
  # Key management (Ed25519)
68
  # ============================================================
 
214
  if not sid:
215
  return None, "Missing session_id."
216
 
217
+ # IMPORTANT: Atomic critical section:
218
+ # read -> compute seq/prev -> build event -> append
219
+ try:
220
+ with _lock_for(log_path):
221
+ all_events, _corrupt = read_jsonl(log_path)
222
 
223
+ payload = parse_payload_text(payload_text)
224
+ meta = {
225
+ "model_id": (model_id or "unknown").strip(),
226
+ "run_mode": (run_mode or "unknown").strip(),
227
+ }
228
 
229
+ last = last_event_for_session(all_events, sid)
230
+ prev_hash = last.get("event_hash_sha256") if last else ("0" * 64)
231
+ seq = next_seq(all_events, sid)
232
+
233
+ # If parent hash not provided, default to prev hash (simple linear causality)
234
+ parent = (parent_event_hash or "").strip()
235
+ if not parent and seq > 1:
236
+ parent = prev_hash
237
+
238
+ core = make_event_core(
239
+ session_id=sid,
240
+ seq=seq,
241
+ event_type=event_type,
242
+ payload=payload,
243
+ parent_event_hash=parent,
244
+ prev_event_hash=prev_hash,
245
+ meta=meta,
246
+ )
247
 
248
+ event_hash = sha256_hex(canon(core))
249
+ event = dict(core)
250
+ event["event_hash_sha256"] = event_hash
 
251
 
252
+ if sign_event:
253
+ if not sk_hex or not sk_hex.strip():
254
+ return None, "Signing enabled but private key is missing."
255
+ try:
256
+ sk = load_sk(sk_hex)
257
+ sig = sk.sign(bytes.fromhex(event_hash))
258
+ event["signature_ed25519"] = sig.hex()
259
+ except Exception:
260
+ return None, "Failed to sign event (invalid private key?)."
261
 
262
+ append_jsonl(log_path, event)
 
 
263
 
264
+ return event, f"OK. Appended event #{seq} ({event_type}). Hash: {event_hash}"
 
 
 
 
 
 
 
 
265
 
266
+ except Timeout:
267
+ return None, "Busy: log is locked (try again)."
268
+ except Exception:
269
+ return None, "Failed to append event (unexpected error)."
270
 
271
 
272
  def start_session(
 
308
  if not sid:
309
  return None, "Missing session_id."
310
 
311
+ try:
312
+ # Lock while reading events + appending session_end (append_event is locked too,
313
+ # but we want a consistent anchor over a stable snapshot of the file)
314
+ with _lock_for(log_path):
315
+ all_events, _corrupt = read_jsonl(log_path)
316
+ evs = events_for_session(all_events, sid)
317
+ if not evs:
318
+ return None, "No events found for this session."
 
 
 
 
 
 
 
 
 
319
 
320
+ # Ensure correct order
321
+ evs.sort(key=lambda x: int(x.get("seq", 0)))
 
 
 
322
 
323
+ first_hash = evs[0]["event_hash_sha256"]
324
+ last_hash = evs[-1]["event_hash_sha256"]
325
+ count = len(evs)
 
 
 
 
 
 
326
 
327
+ root_core = {
328
+ "spec": ROOT_SPEC,
329
+ "session_id": sid,
330
+ "first_event_hash_sha256": first_hash,
331
+ "last_event_hash_sha256": last_hash,
332
+ "event_count": count,
333
+ }
334
+ root_hash = sha256_hex(canon(root_core))
335
+
336
+ anchor = dict(root_core)
337
+ anchor["root_hash_sha256"] = root_hash
338
+ anchor["created_utc"] = now_utc_iso()
339
+ anchor["model_id"] = (model_id or "unknown").strip()
340
+ anchor["run_mode"] = (run_mode or "unknown").strip()
341
+
342
+ if sign_anchor:
343
+ if not sk_hex or not sk_hex.strip():
344
+ return None, "Anchor signing enabled but private key is missing."
345
+ try:
346
+ sk = load_sk(sk_hex)
347
+ sig = sk.sign(bytes.fromhex(root_hash))
348
+ anchor["signature_ed25519"] = sig.hex()
349
+ except Exception:
350
+ return None, "Failed to sign anchor (invalid private key?)."
351
+
352
+ # Append session_end inside the same lock to prevent new events slipping in
353
+ payload_text = json.dumps({"anchor": anchor}, ensure_ascii=False)
354
+
355
+ # Build the session_end event manually so it stays inside our lock
356
+ # (we avoid calling append_event here to prevent nested lock complexity)
357
+ meta = {
358
+ "model_id": (model_id or "unknown").strip(),
359
+ "run_mode": (run_mode or "unknown").strip(),
360
+ }
361
+ seq = int(evs[-1].get("seq", 0)) + 1
362
+ prev_hash = evs[-1]["event_hash_sha256"]
363
+ parent = prev_hash
364
+
365
+ core = make_event_core(
366
+ session_id=sid,
367
+ seq=seq,
368
+ event_type="session_end",
369
+ payload=parse_payload_text(payload_text),
370
+ parent_event_hash=parent,
371
+ prev_event_hash=prev_hash,
372
+ meta=meta,
373
+ )
374
+ event_hash = sha256_hex(canon(core))
375
+ event = dict(core)
376
+ event["event_hash_sha256"] = event_hash
377
+
378
+ if sign_anchor:
379
+ if not sk_hex or not sk_hex.strip():
380
+ return None, "Anchor signing enabled but private key is missing."
381
+ try:
382
+ sk = load_sk(sk_hex)
383
+ sig = sk.sign(bytes.fromhex(event_hash))
384
+ event["signature_ed25519"] = sig.hex()
385
+ except Exception:
386
+ return None, "Failed to sign session_end event."
387
+
388
+ append_jsonl(log_path, event)
389
 
390
+ return anchor, f"OK. Session finalised. Root hash: {root_hash} (last event hash: {event_hash})"
391
+
392
+ except Timeout:
393
+ return None, "Busy: log is locked (try again)."
394
+ except Exception:
395
+ return None, "Failed to finalise session (unexpected error)."
396
 
397
 
398
  # ============================================================
 
514
 
515
 
516
  def verify_session(log_path: str, session_id: str, pk_hex: str, require_signatures: bool) -> Tuple[str, bool, str]:
517
+ try:
518
+ all_events, _corrupt = _read_jsonl_locked(log_path)
519
+ except Timeout:
520
+ return "FAIL", False, "Busy: log is locked (try again)."
521
  evs = events_for_session(all_events, session_id)
 
522
  evs.sort(key=lambda x: int(x.get("seq", 0)))
523
  return verify_session_from_events(evs, session_id, pk_hex=pk_hex, require_signatures=require_signatures)
524
 
 
532
  if not sid:
533
  return [], "Missing session_id."
534
 
535
+ try:
536
+ all_events, _corrupt = _read_jsonl_locked(log_path)
537
+ except Timeout:
538
+ return [], "Busy: log is locked (try again)."
539
+
540
  evs = events_for_session(all_events, sid)
541
  if not evs:
542
  return [], "No events found."
 
565
  if not sid or not h:
566
  return None, "Missing session_id or event hash."
567
 
568
+ try:
569
+ all_events, _corrupt = _read_jsonl_locked(log_path)
570
+ except Timeout:
571
+ return None, "Busy: log is locked (try again)."
572
+
573
  evs = events_for_session(all_events, sid)
574
  for e in evs:
575
  if e.get("event_hash_sha256") == h:
 
578
 
579
 
580
  def list_sessions(log_path: str) -> Tuple[List[str], str]:
581
+ try:
582
+ all_events, corrupt = _read_jsonl_locked(log_path)
583
+ except Timeout:
584
+ return [], "Busy: log is locked (try again)."
585
+
586
  counts: Dict[str, int] = {}
587
  for e in all_events:
588
  if isinstance(e, dict) and e.get("spec") == EVENT_SPEC:
 
595
 
596
 
597
  def diagnostics(log_path: str) -> Dict[str, Any]:
598
+ try:
599
+ all_events, corrupt = _read_jsonl_locked(log_path)
600
+ except Timeout:
601
+ return {
602
+ "log_path": log_path,
603
+ "exists": os.path.exists(log_path),
604
+ "bytes": os.path.getsize(log_path) if os.path.exists(log_path) else 0,
605
+ "total_events": 0,
606
+ "sessions": 0,
607
+ "signed_events": 0,
608
+ "corrupt_lines_ignored": 0,
609
+ "error": "Busy: log is locked (try again).",
610
+ }
611
+
612
  size = os.path.getsize(log_path) if os.path.exists(log_path) else 0
613
  sessions = set()
614
  signed = 0
 
640
  if not sid:
641
  return None, "Missing session_id."
642
 
643
+ try:
644
+ all_events, _corrupt = _read_jsonl_locked(log_path)
645
+ except Timeout:
646
+ return None, "Busy: log is locked (try again)."
647
+
648
  evs = events_for_session(all_events, sid)
649
  if not evs:
650
  return None, "No events found."
 
744
  )
745
 
746
  if store_into_log and ok:
747
+ # Store under a single lock to avoid interleaving with live writers.
748
+ try:
749
+ with _lock_for(log_path):
750
+ for e in evs:
751
+ append_jsonl(log_path, e)
752
+ except Timeout:
753
+ return "FAIL", False, "Busy: log is locked (try again).", None
754
 
755
  stored_msg = "Stored into local flightlog.jsonl." if (store_into_log and ok) else None
756
  return status, ok, report, stored_msg