feat(archive): complete recorded session lifecycle
This commit is contained in:
@@ -23,6 +23,7 @@ from k1link.sessions import (
|
||||
RecordedMediaInspector,
|
||||
ReplayCommand,
|
||||
SessionIntegrityError,
|
||||
SessionNotFoundError,
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
@@ -246,6 +247,116 @@ def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Pat
|
||||
assert_no_local_paths(value, repository)
|
||||
|
||||
|
||||
def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
|
||||
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
||||
payload = b"deletable-recording"
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
store.data_dir,
|
||||
exporter=export_recording,
|
||||
)
|
||||
recording, release = materializer.materialize_pinned(store.prepare_replay(session.name))
|
||||
router = build_session_router(store, recording_materializer=materializer)
|
||||
delete_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "DELETE")
|
||||
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
asyncio.run(delete_route(session_id=session.name))
|
||||
assert conflict.value.status_code == 409
|
||||
assert session.is_dir()
|
||||
assert recording.path.is_file()
|
||||
|
||||
release()
|
||||
response = asyncio.run(delete_route(session_id=session.name))
|
||||
|
||||
assert response.status_code == 204
|
||||
assert not session.exists()
|
||||
assert not recording.path.parent.exists()
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(session.name)
|
||||
|
||||
|
||||
def test_completed_recording_get_does_not_hold_delete_for_launch_lease(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
payload = b"recording-opened-and-closed"
|
||||
|
||||
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1_000_000_000,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(store.data_dir, exporter=export_recording)
|
||||
command = store.prepare_replay(session.name)
|
||||
materializer.materialize(command)
|
||||
manager = SessionRecordingPreparationManager(materializer)
|
||||
resolved = manager.resolve_cached(command)
|
||||
assert resolved is not None and resolved.recording is not None
|
||||
router = build_session_router(
|
||||
store,
|
||||
recording_materializer=materializer,
|
||||
recording_preparation_manager=manager,
|
||||
)
|
||||
replay_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/replay",
|
||||
"POST",
|
||||
)
|
||||
recording_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/recording.rrd",
|
||||
"GET",
|
||||
)
|
||||
delete_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "DELETE")
|
||||
try:
|
||||
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
||||
generation = replay["launch"]["sha256"]
|
||||
file_response = asyncio.run(
|
||||
recording_route(
|
||||
session_id=session.name,
|
||||
generation=generation,
|
||||
if_match=None,
|
||||
if_none_match=None,
|
||||
)
|
||||
)
|
||||
_, body = asyncio.run(render_file_response(file_response, range_header=None))
|
||||
assert body == payload
|
||||
|
||||
deleted = asyncio.run(delete_route(session_id=session.name))
|
||||
|
||||
assert deleted.status_code == 204
|
||||
assert not session.exists()
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -433,3 +433,38 @@ def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path)
|
||||
assert not first_recording.path.exists()
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_first_recording_get_pin_consumes_launch_reservation(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
return _summary(source, destination, b"recording")
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=exporter,
|
||||
free_space_reserve_bytes=0,
|
||||
)
|
||||
manager = SessionRecordingPreparationManager(materializer)
|
||||
try:
|
||||
materializer.materialize(command)
|
||||
resolved = manager.resolve_cached(command)
|
||||
assert resolved is not None and resolved.state == "ready"
|
||||
reserved = manager.reserve_cached(command, lease_seconds=60.0)
|
||||
assert reserved is not None
|
||||
|
||||
pinned = manager.pin_ready(
|
||||
command.session_id,
|
||||
preparation_id=reserved.preparation_id,
|
||||
)
|
||||
|
||||
assert pinned is not None
|
||||
_, release_response = pinned
|
||||
assert manager.release_launch_reservation(command.session_id) is False
|
||||
assert materializer.delete_cached(command.session_id) is False
|
||||
|
||||
release_response()
|
||||
assert materializer.delete_cached(command.session_id) is True
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
@@ -129,6 +129,25 @@ def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Pat
|
||||
assert exporter.calls == 1
|
||||
|
||||
|
||||
def test_delete_cached_refuses_a_live_lease_then_removes_the_exact_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "source")
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=FakeExporter(),
|
||||
)
|
||||
recording, release = materializer.materialize_pinned(command)
|
||||
|
||||
assert materializer.delete_cached(command.session_id) is False
|
||||
assert recording.path.is_file()
|
||||
|
||||
release()
|
||||
assert materializer.delete_cached(command.session_id) is True
|
||||
assert not recording.path.parent.exists()
|
||||
assert command.primary_artifact.path.is_file()
|
||||
|
||||
|
||||
def test_materializer_rejects_changed_digest_bound_secondary_artifact(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -178,6 +178,39 @@ def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Pa
|
||||
assert store.list_recent().items == ()
|
||||
|
||||
|
||||
def test_corrupt_candidate_does_not_starve_later_valid_session(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
corrupt = sessions / "20260716T125502Z_viewer_live"
|
||||
corrupt.mkdir(parents=True)
|
||||
(corrupt / "manifest.redacted.json").write_text("{}", encoding="utf-8")
|
||||
valid = make_legacy_session(sessions, "20260718T201659Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
assert store.reconcile_archive(xgrids_k1_archive_source(sessions)) == (valid.name,)
|
||||
assert [item.session_id for item in store.list_recent().items] == [valid.name]
|
||||
|
||||
|
||||
def test_present_corrupt_candidate_does_not_delete_its_last_valid_catalog_row(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
retained = make_legacy_session(sessions, "20260716T125502Z_viewer_live")
|
||||
later = make_legacy_session(sessions, "20260718T201659Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
source = xgrids_k1_archive_source(sessions)
|
||||
assert store.reconcile_archive(source) == (retained.name, later.name)
|
||||
|
||||
shutil.rmtree(retained / "captures")
|
||||
|
||||
assert store.reconcile_archive(source) == (later.name,)
|
||||
assert {item.session_id for item in store.list_recent().items} == {
|
||||
retained.name,
|
||||
later.name,
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_uses_valid_project_name_as_session_display_name(tmp_path: Path) -> None:
|
||||
sessions = tmp_path / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
@@ -671,6 +704,45 @@ def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Pat
|
||||
assert store.get_session(session.name).summary.replayable is True
|
||||
|
||||
|
||||
def test_delete_session_removes_only_the_exact_evidence_tree_and_catalog_row(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
deleted = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
retained = make_legacy_session(sessions, "20260716T205633Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
|
||||
store.delete_session(deleted.name)
|
||||
|
||||
assert not deleted.exists()
|
||||
assert retained.is_dir()
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(deleted.name)
|
||||
assert store.get_session(retained.name).summary.session_id == retained.name
|
||||
|
||||
|
||||
def test_delete_session_rejects_a_catalog_target_outside_its_allowed_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE observation_sessions SET session_root = ? WHERE session_id = ?",
|
||||
(str(repository), session.name),
|
||||
)
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="escapes"):
|
||||
store.delete_session(session.name)
|
||||
|
||||
assert session.is_dir()
|
||||
|
||||
|
||||
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user