Add isolated Leo learning lifecycle
This commit is contained in:
parent
1472c8513a
commit
8bb10e5c94
2 changed files with 1033 additions and 0 deletions
900
scripts/run_leo_integrated_learning_lifecycle.py
Normal file
900
scripts/run_leo_integrated_learning_lifecycle.py
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run a real source -> review -> apply -> recompile lifecycle in disposable Postgres.
|
||||
|
||||
The harness composes the repository's existing source compiler, normalized
|
||||
proposal staging, review packet, guarded approval, guarded apply, and replay
|
||||
receipt surfaces. It starts its own networkless tmpfs Postgres container and
|
||||
never accepts an existing database target, so the explicit review/apply actions
|
||||
cannot reach VPS, GCP, or another persistent database.
|
||||
|
||||
The source packet must contain:
|
||||
|
||||
* ``prepared/extracted-text.txt``
|
||||
* ``prepared/source-manifest.json``
|
||||
* ``proposal-packet.json``
|
||||
|
||||
The extracted text is used as both the hash-bound source artifact and its strict
|
||||
UTF-8 extraction. ``proposal-packet.json`` is an independently retained
|
||||
compiler expectation: the harness refuses to continue unless a fresh compile
|
||||
matches it byte-for-byte under canonical JSON.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
REPO_ROOT = HERE.parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
import apply_proposal as ap # noqa: E402
|
||||
import compile_kb_source_packet as compiler # noqa: E402
|
||||
import kb_proposal_review_packet as review_packets # noqa: E402
|
||||
import proposal_apply_lifecycle as apply_lifecycle # noqa: E402
|
||||
import stage_normalized_proposal as stage # noqa: E402
|
||||
|
||||
RECEIPT_SCHEMA = "livingip.leoIntegratedLearningLifecycleReceipt.v1"
|
||||
CONTAINER_LABEL = "livingip.canary=leo-integrated-learning-lifecycle"
|
||||
DATABASE = "teleo"
|
||||
REVIEWER_HANDLE = "m3ta"
|
||||
REVIEWER_ID = "11111111-1111-4111-8111-111111111111"
|
||||
DEFAULT_REVIEW_NOTE = (
|
||||
"Approved the exact hash-bound candidate rows for this disposable integrated-learning lifecycle only."
|
||||
)
|
||||
POSTGRES_IMAGE = "postgres:16-alpine"
|
||||
|
||||
|
||||
class LifecycleError(RuntimeError):
|
||||
"""Raised when the isolated lifecycle cannot prove an exact transition."""
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
|
||||
|
||||
def canonical_sha256(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def parse_last_json_line(output: str) -> Any:
|
||||
for line in reversed(output.splitlines()):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
return json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _safe_error_text(value: str, *, limit: int = 1200) -> str:
|
||||
compact = " ".join(value.split())
|
||||
return compact[-limit:]
|
||||
|
||||
|
||||
def command_receipt(result: subprocess.CompletedProcess[str], *, expected_marker: str | None = None) -> dict[str, Any]:
|
||||
parsed = parse_last_json_line(result.stdout)
|
||||
if isinstance(parsed, dict):
|
||||
parsed = {key: value for key, value in parsed.items() if key not in {"receipt_path"}}
|
||||
return {
|
||||
"returncode": result.returncode,
|
||||
"stdout_sha256": sha256_text(result.stdout),
|
||||
"stderr_sha256": sha256_text(result.stderr),
|
||||
"stdout_json": parsed if isinstance(parsed, dict) else None,
|
||||
"expected_marker": expected_marker,
|
||||
"expected_marker_present": expected_marker in result.stdout if expected_marker else None,
|
||||
}
|
||||
|
||||
|
||||
def run_tool(arguments: list[str], *, action: str) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(arguments, text=True, capture_output=True, check=False)
|
||||
if result.returncode != 0:
|
||||
detail = _safe_error_text(result.stderr or result.stdout)
|
||||
raise LifecycleError(f"{action} failed ({result.returncode}): {detail}")
|
||||
return result
|
||||
|
||||
|
||||
class DisposablePostgres:
|
||||
def __init__(self, name: str, admin_password: str) -> None:
|
||||
self.name = name
|
||||
self.admin_password = admin_password
|
||||
self.docker = shutil.which("docker")
|
||||
if not self.docker:
|
||||
raise LifecycleError("docker executable is unavailable")
|
||||
self.started = False
|
||||
self.environment: dict[str, Any] = {}
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
input_text: str | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(
|
||||
[self.docker, *arguments],
|
||||
input=input_text,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
detail = _safe_error_text(result.stderr or result.stdout)
|
||||
raise LifecycleError(f"docker {arguments[0]} failed ({result.returncode}): {detail}")
|
||||
return result
|
||||
|
||||
def start(self) -> dict[str, Any]:
|
||||
result = self._run(
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"-d",
|
||||
"--name",
|
||||
self.name,
|
||||
"--network",
|
||||
"none",
|
||||
"--label",
|
||||
CONTAINER_LABEL,
|
||||
"--label",
|
||||
f"livingip.canary.instance={self.name}",
|
||||
"--tmpfs",
|
||||
"/var/lib/postgresql/data:rw,nosuid,size=384m",
|
||||
"-e",
|
||||
f"POSTGRES_PASSWORD={self.admin_password}",
|
||||
"-e",
|
||||
f"POSTGRES_DB={DATABASE}",
|
||||
POSTGRES_IMAGE,
|
||||
]
|
||||
)
|
||||
self.started = True
|
||||
deadline = time.monotonic() + 45
|
||||
ready_streak = 0
|
||||
while time.monotonic() < deadline:
|
||||
ready = self._run(
|
||||
["exec", self.name, "pg_isready", "-U", "postgres", "-d", DATABASE],
|
||||
check=False,
|
||||
)
|
||||
if ready.returncode == 0:
|
||||
ready_streak += 1
|
||||
if ready_streak >= 2:
|
||||
break
|
||||
else:
|
||||
ready_streak = 0
|
||||
time.sleep(0.25)
|
||||
else:
|
||||
raise LifecycleError("disposable Postgres did not become stably ready")
|
||||
|
||||
inspect = json.loads(self._run(["inspect", self.name]).stdout)[0]
|
||||
mounts = [
|
||||
{
|
||||
"type": mount.get("Type"),
|
||||
"name": mount.get("Name"),
|
||||
"destination": mount.get("Destination"),
|
||||
}
|
||||
for mount in inspect.get("Mounts", [])
|
||||
]
|
||||
labels = inspect.get("Config", {}).get("Labels", {})
|
||||
self.environment = {
|
||||
"container_id": result.stdout.strip(),
|
||||
"container_name": self.name,
|
||||
"image": inspect.get("Config", {}).get("Image"),
|
||||
"network_mode": inspect.get("HostConfig", {}).get("NetworkMode"),
|
||||
"tmpfs_data_dir": "/var/lib/postgresql/data" in inspect.get("HostConfig", {}).get("Tmpfs", {}),
|
||||
"mounts": mounts,
|
||||
"canary_label": labels.get("livingip.canary"),
|
||||
"instance_label": labels.get("livingip.canary.instance"),
|
||||
}
|
||||
if self.environment["network_mode"] != "none":
|
||||
raise LifecycleError("disposable Postgres is not networkless")
|
||||
if not self.environment["tmpfs_data_dir"] or mounts:
|
||||
raise LifecycleError("disposable Postgres must use tmpfs without Docker volumes")
|
||||
if self.environment["canary_label"] != "leo-integrated-learning-lifecycle":
|
||||
raise LifecycleError("disposable Postgres canary label is missing")
|
||||
if self.environment["instance_label"] != self.name:
|
||||
raise LifecycleError("disposable Postgres instance label is missing")
|
||||
return self.environment
|
||||
|
||||
def psql(self, sql: str) -> str:
|
||||
result = self._run(
|
||||
[
|
||||
"exec",
|
||||
"-i",
|
||||
self.name,
|
||||
"psql",
|
||||
"-X",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-d",
|
||||
DATABASE,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-Atq",
|
||||
],
|
||||
input_text=sql,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
def psql_json(self, sql: str) -> Any:
|
||||
output = self.psql(sql)
|
||||
parsed = parse_last_json_line(output)
|
||||
if parsed is None:
|
||||
raise LifecycleError("Postgres readback did not contain JSON")
|
||||
return parsed
|
||||
|
||||
def cleanup(self) -> dict[str, Any]:
|
||||
remove = self._run(["rm", "-f", self.name], check=False) if self.started else None
|
||||
inspect = self._run(["inspect", self.name], check=False)
|
||||
names = self._run(
|
||||
["container", "ls", "-a", "--filter", f"name=^/{self.name}$", "--format", "{{.Names}}"],
|
||||
check=False,
|
||||
)
|
||||
labels = self._run(
|
||||
[
|
||||
"container",
|
||||
"ls",
|
||||
"-a",
|
||||
"--filter",
|
||||
"label=livingip.canary=leo-integrated-learning-lifecycle",
|
||||
"--filter",
|
||||
f"label=livingip.canary.instance={self.name}",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
"remove_attempted": self.started,
|
||||
"remove_returncode": remove.returncode if remove else None,
|
||||
"container_absent": inspect.returncode != 0,
|
||||
"name_readback_clear": names.returncode == 0 and not names.stdout.strip(),
|
||||
"instance_label_readback_clear": labels.returncode == 0 and not labels.stdout.strip(),
|
||||
"network_mode_was_none": self.environment.get("network_mode") == "none",
|
||||
"tmpfs_data_dir_was_present": self.environment.get("tmpfs_data_dir") is True,
|
||||
"docker_volume_mounts_observed": self.environment.get("mounts", []),
|
||||
}
|
||||
|
||||
|
||||
def bootstrap_sql(review_password: str, apply_password: str) -> str:
|
||||
return f"""\\set ON_ERROR_STOP on
|
||||
create role kb_apply login noinherit password {ap.sql_literal(apply_password)};
|
||||
create role kb_review login noinherit password {ap.sql_literal(review_password)};
|
||||
create schema kb_stage;
|
||||
create type evidence_role as enum ('grounds', 'illustrates', 'contradicts');
|
||||
create type edge_type as enum (
|
||||
'supports', 'challenges', 'requires', 'relates', 'contradicts', 'supersedes',
|
||||
'derives_from', 'cites', 'causes', 'constrains', 'accelerates'
|
||||
);
|
||||
|
||||
create table public.agents (
|
||||
id uuid primary key,
|
||||
handle text not null unique,
|
||||
kind text not null
|
||||
);
|
||||
create table public.claims (
|
||||
id uuid primary key,
|
||||
type text not null,
|
||||
text text not null,
|
||||
status text not null,
|
||||
confidence numeric,
|
||||
tags text[] not null default '{{}}',
|
||||
created_by uuid references public.agents(id),
|
||||
superseded_by uuid references public.claims(id),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create table public.sources (
|
||||
id uuid primary key,
|
||||
source_type text not null,
|
||||
url text,
|
||||
storage_path text,
|
||||
excerpt text,
|
||||
hash text not null,
|
||||
created_by uuid references public.agents(id),
|
||||
captured_at timestamptz,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create table public.claim_evidence (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
claim_id uuid not null references public.claims(id),
|
||||
source_id uuid not null references public.sources(id),
|
||||
role evidence_role not null,
|
||||
weight numeric,
|
||||
created_by uuid references public.agents(id),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create table public.claim_edges (
|
||||
id uuid primary key,
|
||||
from_claim uuid not null references public.claims(id),
|
||||
to_claim uuid not null references public.claims(id),
|
||||
edge_type edge_type not null,
|
||||
weight numeric,
|
||||
created_by uuid references public.agents(id),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create table public.reasoning_tools (
|
||||
id uuid primary key,
|
||||
agent_id uuid references public.agents(id),
|
||||
name text not null,
|
||||
description text not null,
|
||||
category text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create table public.strategies (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
agent_id uuid not null references public.agents(id),
|
||||
diagnosis text,
|
||||
guiding_policy text,
|
||||
proximate_objectives jsonb,
|
||||
version integer not null default 1,
|
||||
active boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (agent_id, version)
|
||||
);
|
||||
create table public.strategy_nodes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
agent_id uuid references public.agents(id),
|
||||
node_type text,
|
||||
title text,
|
||||
body text,
|
||||
rank integer,
|
||||
status text not null default 'active',
|
||||
horizon text,
|
||||
measure text,
|
||||
source_ref text,
|
||||
metadata jsonb not null default '{{}}',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create table kb_stage.kb_proposals (
|
||||
id uuid primary key,
|
||||
proposal_type text not null,
|
||||
status text not null,
|
||||
proposed_by_handle text,
|
||||
proposed_by_agent_id uuid references public.agents(id),
|
||||
channel text,
|
||||
source_ref text,
|
||||
rationale text,
|
||||
payload jsonb not null,
|
||||
reviewed_by_handle text,
|
||||
reviewed_by_agent_id uuid references public.agents(id),
|
||||
reviewed_at timestamptz,
|
||||
review_note text,
|
||||
applied_by_handle text,
|
||||
applied_by_agent_id uuid references public.agents(id),
|
||||
applied_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
insert into public.agents (id, handle, kind)
|
||||
values ({ap.sql_literal(REVIEWER_ID)}::uuid, {ap.sql_literal(REVIEWER_HANDLE)}, 'human');
|
||||
"""
|
||||
|
||||
|
||||
def compile_source_packet(source_packet: Path) -> tuple[dict[str, Any], dict[str, Any], dict[str, Path]]:
|
||||
paths = {
|
||||
"artifact": source_packet / "prepared" / "extracted-text.txt",
|
||||
"text": source_packet / "prepared" / "extracted-text.txt",
|
||||
"manifest": source_packet / "prepared" / "source-manifest.json",
|
||||
"expected": source_packet / "proposal-packet.json",
|
||||
}
|
||||
missing = [str(path) for path in paths.values() if not path.is_file()]
|
||||
if missing:
|
||||
raise LifecycleError(f"source packet is missing required files: {missing}")
|
||||
expected = json.loads(paths["expected"].read_text(encoding="utf-8"))
|
||||
if not isinstance(expected, dict):
|
||||
raise LifecycleError("proposal-packet.json must contain one JSON object")
|
||||
compiled = compiler.compile_source_packet(paths["artifact"], paths["text"], paths["manifest"])
|
||||
if canonical_json_bytes(compiled) != canonical_json_bytes(expected):
|
||||
raise LifecycleError("fresh compiler output does not match retained proposal-packet.json")
|
||||
return compiled, expected, paths
|
||||
|
||||
|
||||
def proposal_readback(postgres: DisposablePostgres, proposal_id: str) -> dict[str, Any] | None:
|
||||
sql = f"""select jsonb_build_object(
|
||||
'id', id::text,
|
||||
'proposal_type', proposal_type,
|
||||
'status', status,
|
||||
'proposed_by_handle', proposed_by_handle,
|
||||
'proposed_by_agent_id', proposed_by_agent_id::text,
|
||||
'channel', channel,
|
||||
'source_ref', source_ref,
|
||||
'rationale', rationale,
|
||||
'payload', payload,
|
||||
'reviewed_by_handle', reviewed_by_handle,
|
||||
'reviewed_by_agent_id', reviewed_by_agent_id::text,
|
||||
'reviewed_at', reviewed_at::text,
|
||||
'review_note', review_note,
|
||||
'applied_by_handle', applied_by_handle,
|
||||
'applied_by_agent_id', applied_by_agent_id::text,
|
||||
'applied_at', applied_at::text,
|
||||
'created_at', created_at::text,
|
||||
'updated_at', updated_at::text
|
||||
)::text
|
||||
from kb_stage.kb_proposals
|
||||
where id = {ap.sql_literal(proposal_id)}::uuid;"""
|
||||
parsed = parse_last_json_line(postgres.psql(sql))
|
||||
if parsed is not None and not isinstance(parsed, dict):
|
||||
raise LifecycleError("proposal readback was not an object")
|
||||
return parsed
|
||||
|
||||
|
||||
def approval_readback(postgres: DisposablePostgres, proposal_id: str) -> dict[str, Any] | None:
|
||||
sql = f"""select jsonb_build_object(
|
||||
'proposal_id', proposal_id::text,
|
||||
'proposal_type', proposal_type,
|
||||
'payload', payload,
|
||||
'reviewed_by_handle', reviewed_by_handle,
|
||||
'reviewed_by_agent_id', reviewed_by_agent_id::text,
|
||||
'reviewed_by_db_role', reviewed_by_db_role::text,
|
||||
'reviewed_at', reviewed_at::text,
|
||||
'review_note', review_note
|
||||
)::text
|
||||
from kb_stage.kb_proposal_approvals
|
||||
where proposal_id = {ap.sql_literal(proposal_id)}::uuid;"""
|
||||
parsed = parse_last_json_line(postgres.psql(sql))
|
||||
if parsed is not None and not isinstance(parsed, dict):
|
||||
raise LifecycleError("approval readback was not an object")
|
||||
return parsed
|
||||
|
||||
|
||||
def probe_applied_proposal(child: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
**child,
|
||||
"status": "applied",
|
||||
"applied_by_handle": ap.SERVICE_AGENT_HANDLE,
|
||||
"applied_at": "2000-01-01T00:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
def target_rows_readback(postgres: DisposablePostgres, child: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
|
||||
sql = ap.replay_receipt.build_postflight_sql(probe_applied_proposal(child))
|
||||
parsed = postgres.psql_json(sql)
|
||||
if not isinstance(parsed, dict) or any(not isinstance(rows, list) for rows in parsed.values()):
|
||||
raise LifecycleError("canonical target-row readback was malformed")
|
||||
return parsed
|
||||
|
||||
|
||||
def agents_readback(postgres: DisposablePostgres) -> list[dict[str, Any]]:
|
||||
parsed = postgres.psql_json(
|
||||
"select coalesce(jsonb_agg(to_jsonb(a) order by a.id::text), '[]'::jsonb)::text from public.agents a;"
|
||||
)
|
||||
if not isinstance(parsed, list):
|
||||
raise LifecycleError("agent readback was malformed")
|
||||
return parsed
|
||||
|
||||
|
||||
def database_state(postgres: DisposablePostgres, child: dict[str, Any]) -> dict[str, Any]:
|
||||
proposal_id = str(child["id"])
|
||||
canonical_rows = target_rows_readback(postgres, child)
|
||||
state = {
|
||||
"agents": agents_readback(postgres),
|
||||
"canonical_rows": canonical_rows,
|
||||
"proposal": proposal_readback(postgres, proposal_id),
|
||||
"immutable_approval": approval_readback(postgres, proposal_id),
|
||||
}
|
||||
return {
|
||||
"database_fingerprint": canonical_sha256(state),
|
||||
"canonical_fingerprint": canonical_sha256(canonical_rows),
|
||||
"state": state,
|
||||
}
|
||||
|
||||
|
||||
def exact_identities(
|
||||
child: dict[str, Any], canonical_rows: dict[str, list[dict[str, Any]]] | None = None
|
||||
) -> dict[str, Any]:
|
||||
apply_payload = child["payload"]["apply_payload"]
|
||||
evidence_rows = canonical_rows.get("claim_evidence", []) if canonical_rows else []
|
||||
return {
|
||||
"proposal_id": child["id"],
|
||||
"claim_ids": [row["id"] for row in apply_payload.get("claims", [])],
|
||||
"source_ids": [row["id"] for row in apply_payload.get("sources", [])],
|
||||
"planned_evidence_keys": [
|
||||
{
|
||||
"claim_id": row["claim_id"],
|
||||
"source_id": row["source_id"],
|
||||
"role": row.get("role") or "grounds",
|
||||
}
|
||||
for row in apply_payload.get("evidence", [])
|
||||
],
|
||||
"evidence_ids": [row["id"] for row in evidence_rows if row.get("id")],
|
||||
"edge_ids": [row["id"] for row in canonical_rows.get("claim_edges", [])] if canonical_rows else [],
|
||||
}
|
||||
|
||||
|
||||
def review_packet(proposal: dict[str, Any]) -> dict[str, Any]:
|
||||
packet = review_packets.classify_proposal(proposal)
|
||||
apply_payload = proposal["payload"]["apply_payload"]
|
||||
packet["candidate_rows"] = {
|
||||
family: apply_payload.get(family, [])
|
||||
for family in ("claims", "sources", "evidence", "edges", "reasoning_tools")
|
||||
}
|
||||
packet["packet_sha256"] = canonical_sha256(packet)
|
||||
return packet
|
||||
|
||||
|
||||
def write_secret_file(path: Path, key: str, value: str) -> None:
|
||||
path.write_text(f"{key}={value}\n", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def _tool_base(script: Path, proposal_id: str, secrets_file: Path, role: str, container: str) -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
str(script),
|
||||
proposal_id,
|
||||
"--secrets-file",
|
||||
str(secrets_file),
|
||||
"--container",
|
||||
container,
|
||||
"--db",
|
||||
DATABASE,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--role",
|
||||
role,
|
||||
]
|
||||
|
||||
|
||||
def _all_target_rows_empty(rows: dict[str, list[dict[str, Any]]]) -> bool:
|
||||
return set(rows) == set(apply_lifecycle.ROW_TABLES) and all(not table_rows for table_rows in rows.values())
|
||||
|
||||
|
||||
def _row_counts(rows: dict[str, list[dict[str, Any]]]) -> dict[str, int]:
|
||||
return {family: len(table_rows) for family, table_rows in rows.items()}
|
||||
|
||||
|
||||
def normalized_canonical_rows(rows: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
|
||||
return {family: sorted(table_rows, key=canonical_json_bytes) for family, table_rows in sorted(rows.items())}
|
||||
|
||||
|
||||
def run_lifecycle(
|
||||
source_packet: Path,
|
||||
output: Path,
|
||||
*,
|
||||
review_action: str,
|
||||
reviewed_by: str = REVIEWER_HANDLE,
|
||||
review_note: str = DEFAULT_REVIEW_NOTE,
|
||||
) -> dict[str, Any]:
|
||||
if review_action != "approve":
|
||||
raise LifecycleError("the only supported explicit review action is 'approve'")
|
||||
if reviewed_by != REVIEWER_HANDLE:
|
||||
raise LifecycleError(f"disposable reviewer must be {REVIEWER_HANDLE!r}")
|
||||
if not review_note.strip():
|
||||
raise LifecycleError("review note must be non-empty")
|
||||
|
||||
source_packet = source_packet.resolve()
|
||||
output = output.resolve()
|
||||
temp_root = Path(tempfile.mkdtemp(prefix="leo-integrated-learning-"))
|
||||
private_apply_receipt_path = temp_root / "apply-receipt.json"
|
||||
container_name = f"leo-integrated-learning-{os.getpid()}-{uuid.uuid4().hex[:10]}"
|
||||
postgres = DisposablePostgres(container_name, secrets.token_urlsafe(32))
|
||||
receipt: dict[str, Any] = {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"status": "fail",
|
||||
"required_tier": "realistic_isolated_local_container_end_to_end",
|
||||
"current_tier": "not_proven",
|
||||
"source_packet": str(source_packet),
|
||||
"review_action": review_action,
|
||||
"safety_boundary": {
|
||||
"self_created_container_only": True,
|
||||
"existing_database_target_argument_supported": False,
|
||||
"vps_or_gcp_target_supported": False,
|
||||
"telegram_send_performed": False,
|
||||
},
|
||||
"checks": {},
|
||||
}
|
||||
|
||||
try:
|
||||
compiled, expected, paths = compile_source_packet(source_packet)
|
||||
child = compiled["strict_child_proposal"]
|
||||
parent = compiled["parent_proposal"]
|
||||
proposal_id = str(child["id"])
|
||||
apply_payload = child["payload"]["apply_payload"]
|
||||
manifest = json.loads(paths["manifest"].read_text(encoding="utf-8"))
|
||||
receipt["source"] = {
|
||||
"source_identity": manifest["source"]["identity"],
|
||||
"source_locator": manifest["source"]["locator"],
|
||||
"artifact_sha256": sha256_file(paths["artifact"]),
|
||||
"manifest_canonical_sha256": canonical_sha256(manifest),
|
||||
"retained_proposal_packet_canonical_sha256": canonical_sha256(expected),
|
||||
}
|
||||
receipt["compiled"] = {
|
||||
"status": compiled["status"],
|
||||
"bundle_sha256": canonical_sha256(compiled),
|
||||
"parent_proposal_id": parent["id"],
|
||||
"strict_proposal_id": proposal_id,
|
||||
"candidate_counts": {
|
||||
family: len(apply_payload.get(family, []))
|
||||
for family in ("claims", "sources", "evidence", "edges", "reasoning_tools")
|
||||
},
|
||||
}
|
||||
compiled_identities = exact_identities(child)
|
||||
receipt["identities"] = compiled_identities
|
||||
|
||||
environment = postgres.start()
|
||||
receipt["environment"] = environment
|
||||
review_password = secrets.token_urlsafe(32)
|
||||
apply_password = secrets.token_urlsafe(32)
|
||||
review_secrets = temp_root / "kb-review.env"
|
||||
apply_secrets = temp_root / "kb-apply.env"
|
||||
preflight_path = temp_root / "fresh-preflight.json"
|
||||
write_secret_file(review_secrets, "KB_REVIEW_PASSWORD", review_password)
|
||||
write_secret_file(apply_secrets, "KB_APPLY_PASSWORD", apply_password)
|
||||
|
||||
postgres.psql(bootstrap_sql(review_password, apply_password))
|
||||
postgres.psql((HERE / "kb_apply_prereqs.sql").read_text(encoding="utf-8"))
|
||||
|
||||
initial = database_state(postgres, child)
|
||||
stage_result = postgres.psql_json(stage.build_stage_sql(child))
|
||||
pending = proposal_readback(postgres, proposal_id)
|
||||
if pending is None:
|
||||
raise LifecycleError("staging did not persist the exact proposal")
|
||||
staged = database_state(postgres, child)
|
||||
packet = review_packet(pending)
|
||||
receipt["phases"] = {
|
||||
"initial": initial,
|
||||
"staged_pending_review": {
|
||||
"database_fingerprint": staged["database_fingerprint"],
|
||||
"canonical_fingerprint": staged["canonical_fingerprint"],
|
||||
"proposal": pending,
|
||||
"stage_result": stage_result,
|
||||
"review_packet": packet,
|
||||
},
|
||||
}
|
||||
|
||||
approve_base = [
|
||||
*_tool_base(
|
||||
HERE / "approve_proposal.py",
|
||||
proposal_id,
|
||||
review_secrets,
|
||||
"kb_review",
|
||||
container_name,
|
||||
),
|
||||
"--reviewed-by",
|
||||
reviewed_by,
|
||||
"--review-note",
|
||||
review_note,
|
||||
]
|
||||
approve_dry_run = run_tool([*approve_base, "--dry-run"], action="approval dry-run")
|
||||
if "kb_stage.approve_strict_proposal" not in approve_dry_run.stdout:
|
||||
raise LifecycleError("approval dry-run did not bind the guarded approval function")
|
||||
approve_execute = run_tool(approve_base, action="explicit review approval")
|
||||
approved = proposal_readback(postgres, proposal_id)
|
||||
immutable_approval = approval_readback(postgres, proposal_id)
|
||||
if approved is None or immutable_approval is None:
|
||||
raise LifecycleError("explicit review did not create both approved state and immutable approval")
|
||||
approved_state = database_state(postgres, child)
|
||||
receipt["phases"]["approved_unapplied"] = {
|
||||
"database_fingerprint": approved_state["database_fingerprint"],
|
||||
"canonical_fingerprint": approved_state["canonical_fingerprint"],
|
||||
"proposal": approved,
|
||||
"immutable_approval": immutable_approval,
|
||||
"review_action": {
|
||||
"reviewed_by": reviewed_by,
|
||||
"review_note": review_note,
|
||||
"dry_run": command_receipt(
|
||||
approve_dry_run,
|
||||
expected_marker="kb_stage.approve_strict_proposal",
|
||||
),
|
||||
"execute": command_receipt(approve_execute),
|
||||
},
|
||||
}
|
||||
|
||||
target_rows_before = target_rows_readback(postgres, child)
|
||||
preflight = apply_lifecycle.build_fresh_preflight(approved, target_rows_before)
|
||||
preflight_path.write_text(json.dumps(preflight, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
preflight_path.chmod(0o600)
|
||||
apply_base = [
|
||||
*_tool_base(
|
||||
HERE / "apply_proposal.py",
|
||||
proposal_id,
|
||||
apply_secrets,
|
||||
"kb_apply",
|
||||
container_name,
|
||||
),
|
||||
"--fresh-preflight",
|
||||
str(preflight_path),
|
||||
"--receipt-out",
|
||||
str(private_apply_receipt_path),
|
||||
]
|
||||
apply_dry_run = run_tool([*apply_base, "--dry-run"], action="guarded apply dry-run")
|
||||
apply_markers = (
|
||||
"kb_stage.assert_approved_proposal",
|
||||
"kb_stage.finish_approved_proposal",
|
||||
)
|
||||
if any(marker not in apply_dry_run.stdout for marker in apply_markers):
|
||||
raise LifecycleError("apply dry-run did not bind both approval guards")
|
||||
apply_execute = run_tool(apply_base, action="guarded canonical apply")
|
||||
if not private_apply_receipt_path.is_file():
|
||||
raise LifecycleError("guarded apply did not retain its private replay receipt")
|
||||
apply_receipt = json.loads(private_apply_receipt_path.read_text(encoding="utf-8"))
|
||||
applied = proposal_readback(postgres, proposal_id)
|
||||
if applied is None:
|
||||
raise LifecycleError("applied proposal readback is missing")
|
||||
final_rows = target_rows_readback(postgres, child)
|
||||
applied_state = database_state(postgres, child)
|
||||
|
||||
recompiled, _expected_again, _paths_again = compile_source_packet(source_packet)
|
||||
recompiled_child = recompiled["strict_child_proposal"]
|
||||
final_identities = exact_identities(recompiled_child, final_rows)
|
||||
receipt["identities"] = final_identities
|
||||
receipt["phases"]["applied"] = {
|
||||
"database_fingerprint": applied_state["database_fingerprint"],
|
||||
"canonical_fingerprint": applied_state["canonical_fingerprint"],
|
||||
"proposal": applied,
|
||||
"canonical_rows": final_rows,
|
||||
"apply_action": {
|
||||
"dry_run": {
|
||||
**command_receipt(apply_dry_run),
|
||||
"approval_guards_present": all(marker in apply_dry_run.stdout for marker in apply_markers),
|
||||
},
|
||||
"execute": command_receipt(apply_execute),
|
||||
},
|
||||
"apply_receipt": apply_receipt,
|
||||
}
|
||||
receipt["phases"]["recompiled_readback"] = {
|
||||
"bundle_sha256": canonical_sha256(recompiled),
|
||||
"matches_initial_compile": canonical_json_bytes(recompiled) == canonical_json_bytes(compiled),
|
||||
"proposal_id_matches": recompiled_child["id"] == proposal_id,
|
||||
"claim_ids_match": final_identities["claim_ids"] == compiled_identities["claim_ids"],
|
||||
"source_ids_match": final_identities["source_ids"] == compiled_identities["source_ids"],
|
||||
"canonical_rows_sha256": canonical_sha256(normalized_canonical_rows(final_rows)),
|
||||
"apply_receipt_rows_sha256": canonical_sha256(
|
||||
normalized_canonical_rows(apply_receipt.get("canonical_rows"))
|
||||
),
|
||||
}
|
||||
|
||||
expected_counts = ap.replay_receipt.expected_row_counts(applied)
|
||||
actual_counts = _row_counts(final_rows)
|
||||
checks = {
|
||||
"fresh_compile_matches_retained_packet": canonical_json_bytes(compiled) == canonical_json_bytes(expected),
|
||||
"compiler_emits_pending_review": compiled.get("status") == "pending_review",
|
||||
"initial_target_rows_absent": _all_target_rows_empty(initial["state"]["canonical_rows"]),
|
||||
"staged_proposal_exact": pending.get("id") == proposal_id and pending.get("payload") == child["payload"],
|
||||
"staged_status_pending_review": pending.get("status") == "pending_review",
|
||||
"review_packet_requires_human_review": packet.get("review_state") == "needs_human_review",
|
||||
"stage_did_not_change_canonical_rows": staged["canonical_fingerprint"] == initial["canonical_fingerprint"],
|
||||
"explicit_review_status_approved": approved.get("status") == "approved",
|
||||
"approved_is_not_applied": approved.get("applied_at") is None,
|
||||
"immutable_approval_matches_payload": immutable_approval.get("payload") == child["payload"],
|
||||
"immutable_approval_matches_reviewer": immutable_approval.get("reviewed_by_handle") == reviewed_by,
|
||||
"review_did_not_change_canonical_rows": approved_state["canonical_fingerprint"]
|
||||
== initial["canonical_fingerprint"],
|
||||
"apply_status_applied": applied.get("status") == "applied" and bool(applied.get("applied_at")),
|
||||
"apply_changed_canonical_fingerprint": applied_state["canonical_fingerprint"]
|
||||
!= initial["canonical_fingerprint"],
|
||||
"apply_row_counts_exact": actual_counts == expected_counts,
|
||||
"apply_receipt_replay_ready": apply_receipt.get("replay_ready") is True,
|
||||
"apply_receipt_rows_exact": normalized_canonical_rows(apply_receipt.get("canonical_rows"))
|
||||
== normalized_canonical_rows(final_rows),
|
||||
"recompile_matches_initial": canonical_json_bytes(recompiled) == canonical_json_bytes(compiled),
|
||||
"recompile_proposal_id_exact": recompiled_child["id"] == proposal_id,
|
||||
"database_fingerprints_distinguish_all_transitions": len(
|
||||
{
|
||||
initial["database_fingerprint"],
|
||||
staged["database_fingerprint"],
|
||||
approved_state["database_fingerprint"],
|
||||
applied_state["database_fingerprint"],
|
||||
}
|
||||
)
|
||||
== 4,
|
||||
}
|
||||
receipt["checks"] = checks
|
||||
if not all(checks.values()):
|
||||
failed = [name for name, passed in checks.items() if not passed]
|
||||
raise LifecycleError(f"lifecycle checks failed: {failed}")
|
||||
except (LifecycleError, OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
receipt["error"] = {"type": type(exc).__name__, "message": str(exc)}
|
||||
finally:
|
||||
cleanup = postgres.cleanup()
|
||||
shutil.rmtree(temp_root, ignore_errors=True)
|
||||
cleanup["temporary_secret_root_absent"] = not temp_root.exists()
|
||||
cleanup["private_apply_receipt_removed_with_secret_root"] = not private_apply_receipt_path.exists()
|
||||
cleanup["all"] = all(
|
||||
(
|
||||
cleanup.get("container_absent") is True,
|
||||
cleanup.get("name_readback_clear") is True,
|
||||
cleanup.get("instance_label_readback_clear") is True,
|
||||
cleanup.get("network_mode_was_none") is True,
|
||||
cleanup.get("tmpfs_data_dir_was_present") is True,
|
||||
cleanup.get("docker_volume_mounts_observed") == [],
|
||||
cleanup.get("temporary_secret_root_absent") is True,
|
||||
cleanup.get("private_apply_receipt_removed_with_secret_root") is True,
|
||||
)
|
||||
)
|
||||
receipt["cleanup"] = cleanup
|
||||
receipt["checks"]["container_and_private_temp_cleanup_proven"] = cleanup["all"]
|
||||
receipt["status"] = (
|
||||
"pass" if "error" not in receipt and receipt["checks"] and all(receipt["checks"].values()) else "fail"
|
||||
)
|
||||
receipt["current_tier"] = (
|
||||
"realistic_isolated_local_container_end_to_end"
|
||||
if receipt["status"] == "pass"
|
||||
else "runtime_verification_failed"
|
||||
)
|
||||
receipt["strongest_claim"] = (
|
||||
"A retained real source packet was freshly compiled, staged, explicitly reviewed, guarded-applied "
|
||||
"into networkless disposable Postgres, deterministically recompiled, and read back with exact rows; "
|
||||
"the container and private temporary files were then independently absent."
|
||||
if receipt["status"] == "pass"
|
||||
else "The integrated-learning lifecycle did not pass all fail-closed checks."
|
||||
)
|
||||
receipt["not_proven"] = [
|
||||
"VPS or GCP canonical apply",
|
||||
"live Telegram send or reply",
|
||||
"human acceptance of these candidate claims for production",
|
||||
]
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary_output = output.with_name(f".{output.name}.{uuid.uuid4().hex}.tmp")
|
||||
temporary_output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(temporary_output, output)
|
||||
return receipt
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source-packet", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument(
|
||||
"--review-action",
|
||||
required=True,
|
||||
choices=("approve",),
|
||||
help="explicit disposable-only review action; no default is accepted",
|
||||
)
|
||||
parser.add_argument("--reviewed-by", default=REVIEWER_HANDLE)
|
||||
parser.add_argument("--review-note", default=DEFAULT_REVIEW_NOTE)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
receipt = run_lifecycle(
|
||||
args.source_packet,
|
||||
args.output,
|
||||
review_action=args.review_action,
|
||||
reviewed_by=args.reviewed_by,
|
||||
review_note=args.review_note,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": receipt["status"],
|
||||
"receipt": str(args.output.resolve()),
|
||||
"proposal_id": receipt.get("identities", {}).get("proposal_id"),
|
||||
"current_tier": receipt["current_tier"],
|
||||
"cleanup": receipt["cleanup"].get("all"),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0 if receipt["status"] == "pass" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
133
tests/test_run_leo_integrated_learning_lifecycle.py
Normal file
133
tests/test_run_leo_integrated_learning_lifecycle.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import compile_kb_source_packet as compiler # noqa: E402
|
||||
import run_leo_integrated_learning_lifecycle as lifecycle # noqa: E402
|
||||
|
||||
|
||||
def _write_source_packet(tmp_path: Path) -> Path:
|
||||
packet = tmp_path / "source-packet"
|
||||
prepared = packet / "prepared"
|
||||
prepared.mkdir(parents=True)
|
||||
artifact = REPO_ROOT / "fixtures" / "working-leo" / "document-ingestion-v1.json"
|
||||
manifest = REPO_ROOT / "fixtures" / "working-leo" / "source-compiler-manifest-v1.json"
|
||||
extracted = prepared / "extracted-text.txt"
|
||||
manifest_target = prepared / "source-manifest.json"
|
||||
shutil.copyfile(artifact, extracted)
|
||||
shutil.copyfile(manifest, manifest_target)
|
||||
expected = compiler.compile_source_packet(extracted, extracted, manifest_target)
|
||||
(packet / "proposal-packet.json").write_text(
|
||||
json.dumps(expected, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return packet
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if not shutil.which("docker"):
|
||||
return False
|
||||
result = subprocess.run(
|
||||
["docker", "info", "--format", "{{.ServerVersion}}"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def test_source_packet_recompiles_to_retained_expectation(tmp_path: Path) -> None:
|
||||
packet = _write_source_packet(tmp_path)
|
||||
|
||||
compiled, expected, paths = lifecycle.compile_source_packet(packet)
|
||||
|
||||
assert compiled == expected
|
||||
assert lifecycle.sha256_file(paths["artifact"]) == expected["validated_manifest"]["artifact_sha256"]
|
||||
assert compiled["status"] == "pending_review"
|
||||
assert compiled["strict_child_proposal"]["status"] == "pending_review"
|
||||
|
||||
|
||||
def test_source_packet_refuses_retained_packet_drift(tmp_path: Path) -> None:
|
||||
packet = _write_source_packet(tmp_path)
|
||||
expected_path = packet / "proposal-packet.json"
|
||||
expected = json.loads(expected_path.read_text(encoding="utf-8"))
|
||||
expected["strict_child_proposal"]["status"] = "approved"
|
||||
expected_path.write_text(json.dumps(expected), encoding="utf-8")
|
||||
|
||||
with pytest.raises(lifecycle.LifecycleError, match="does not match retained"):
|
||||
lifecycle.compile_source_packet(packet)
|
||||
|
||||
|
||||
def test_exact_identities_adds_observed_evidence_ids(tmp_path: Path) -> None:
|
||||
packet = _write_source_packet(tmp_path)
|
||||
compiled, _expected, _paths = lifecycle.compile_source_packet(packet)
|
||||
child = compiled["strict_child_proposal"]
|
||||
apply_payload = child["payload"]["apply_payload"]
|
||||
planned = apply_payload["evidence"][0]
|
||||
evidence_id = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
|
||||
|
||||
identities = lifecycle.exact_identities(
|
||||
child,
|
||||
{
|
||||
"claim_evidence": [{"id": evidence_id, **planned}],
|
||||
"claim_edges": [],
|
||||
},
|
||||
)
|
||||
|
||||
assert identities["proposal_id"] == child["id"]
|
||||
assert identities["claim_ids"] == [row["id"] for row in apply_payload["claims"]]
|
||||
assert identities["source_ids"] == [row["id"] for row in apply_payload["sources"]]
|
||||
assert identities["planned_evidence_keys"] == [
|
||||
{
|
||||
"claim_id": row["claim_id"],
|
||||
"source_id": row["source_id"],
|
||||
"role": row["role"],
|
||||
}
|
||||
for row in apply_payload["evidence"]
|
||||
]
|
||||
assert identities["evidence_ids"] == [evidence_id]
|
||||
|
||||
|
||||
def test_lifecycle_requires_explicit_approve_action(tmp_path: Path) -> None:
|
||||
with pytest.raises(lifecycle.LifecycleError, match="only supported explicit review action"):
|
||||
lifecycle.run_lifecycle(
|
||||
tmp_path / "unused",
|
||||
tmp_path / "receipt.json",
|
||||
review_action="inspect",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _docker_available(), reason="Docker daemon is required for the isolated lifecycle")
|
||||
def test_disposable_postgres_lifecycle_end_to_end(tmp_path: Path) -> None:
|
||||
packet = _write_source_packet(tmp_path)
|
||||
output = tmp_path / "integrated-learning-receipt.json"
|
||||
|
||||
receipt = lifecycle.run_lifecycle(
|
||||
packet,
|
||||
output,
|
||||
review_action="approve",
|
||||
review_note="Approved exact fixture payload for isolated lifecycle regression coverage.",
|
||||
)
|
||||
|
||||
assert receipt["status"] == "pass", receipt.get("error")
|
||||
assert receipt["current_tier"] == "realistic_isolated_local_container_end_to_end"
|
||||
assert receipt["phases"]["staged_pending_review"]["proposal"]["status"] == "pending_review"
|
||||
assert receipt["phases"]["approved_unapplied"]["proposal"]["status"] == "approved"
|
||||
assert receipt["phases"]["approved_unapplied"]["proposal"]["applied_at"] is None
|
||||
assert receipt["phases"]["applied"]["proposal"]["status"] == "applied"
|
||||
assert receipt["phases"]["applied"]["apply_receipt"]["replay_ready"] is True
|
||||
assert receipt["phases"]["recompiled_readback"]["matches_initial_compile"] is True
|
||||
assert receipt["identities"]["evidence_ids"]
|
||||
assert len(receipt["checks"]) >= 20
|
||||
assert all(receipt["checks"].values())
|
||||
assert receipt["cleanup"]["all"] is True
|
||||
assert json.loads(output.read_text(encoding="utf-8")) == receipt
|
||||
Loading…
Reference in a new issue