Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d4c2ad9b | ||
|
|
2da0afb324 | ||
|
|
aa8ca45302 | ||
|
|
058c183a89 | ||
|
|
62cb334c95 | ||
|
|
2379ba2a70 | ||
|
|
a7215d96be | ||
|
|
e7b7aa2b10 |
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Rune
|
||||
|
||||
**Current release: v0.59.4**
|
||||
**Current release: v0.59.5**
|
||||
|
||||
**Rune is a persistent personal agent designed to accumulate experience, develop reusable competence, and remain recognisably the same agent even when its underlying foundation model changes.**
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rune"
|
||||
version = "0.59.4"
|
||||
version = "0.59.5"
|
||||
description = "Rune — a stateful coding assistant with persistent memory, orchestration, and TUI"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ pygments==2.21.0
|
||||
# pytest
|
||||
# rich
|
||||
# textual
|
||||
pyright==1.1.411
|
||||
pyright==1.1.412
|
||||
# via rune (pyproject.toml)
|
||||
pytest==9.1.1
|
||||
# via
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""publish_source_ledger.py — idempotent Gitea publication for the Source Ledger
|
||||
(PROV-1, #282).
|
||||
|
||||
Pure projection publisher: renders the ledger from the accepted OUTWARD
|
||||
artifact and posts/updates a single OUTWARD SOURCE LEDGER comment on the
|
||||
issue. Idempotency: the comment is identified by a fixed marker containing
|
||||
the artifact hash — re-running with the same hash UPDATES the existing
|
||||
comment instead of creating duplicates.
|
||||
|
||||
Publication failure raises visibly (per #282 constraint: provenance
|
||||
publication fails visibly, must not silently falsify the ledger). It does
|
||||
NOT alter research cognition or stage outcomes — callers invoke it after
|
||||
gate acceptance, and its failure does not invalidate the accepted artifact.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
from source_ledger import build_records, render_markdown
|
||||
|
||||
MARKER_PREFIX = "<!-- source-ledger"
|
||||
|
||||
|
||||
def _api(method: str, path: str, payload: dict | None = None) -> tuple[int, dict | str]:
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
server = os.environ.get("GITEA_SERVER", "https://gitea.thingscouldgetdicey.online")
|
||||
req = urllib.request.Request(
|
||||
server + "/api/v1" + path, method=method,
|
||||
data=json.dumps(payload).encode() if payload else None,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
body = r.read().decode()
|
||||
return r.status, (json.loads(body) if body.strip() else "")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()[:500]
|
||||
|
||||
|
||||
def _marker(artifact_hash: str) -> str:
|
||||
return f"{MARKER_PREFIX} hash={artifact_hash} -->"
|
||||
|
||||
|
||||
def publish(issue: int, artifact_dir: str, artifact_hash: str,
|
||||
runner: dict | None = None, model: str | None = None) -> dict:
|
||||
"""Render + post/update the ledger comment. Returns publication result."""
|
||||
path = os.path.join(artifact_dir, "outward.json")
|
||||
with open(path) as f:
|
||||
outward = json.load(f)
|
||||
|
||||
# runner/model: persisted factual fields from the artifact; UNKNOWN if absent
|
||||
runner = runner or outward.get("runner")
|
||||
model = model or outward.get("model", "UNKNOWN")
|
||||
|
||||
records = build_records(outward)
|
||||
legacy = not any(
|
||||
c.get("contradicts_source_ids")
|
||||
for c in outward.get("contradictory_evidence", outward.get("contradictions", []))
|
||||
if isinstance(c, dict))
|
||||
body = _marker(artifact_hash) + "\n" + render_markdown(
|
||||
records, outward, runner, model, artifact_hash, legacy_claims=legacy)
|
||||
|
||||
# Find existing ledger comment for this artifact hash (idempotency)
|
||||
status, comments = _api("GET", f"/repos/conductor/rune/issues/{issue}/comments?limit=50")
|
||||
if status != 200 or not isinstance(comments, list):
|
||||
raise RuntimeError(f"SourceLedger publish: cannot list comments (HTTP {status})")
|
||||
existing = None
|
||||
for c in comments:
|
||||
if isinstance(c, dict) and c.get("body", "").startswith(_marker(artifact_hash)):
|
||||
existing = c
|
||||
break
|
||||
|
||||
if existing:
|
||||
status, _ = _api("PATCH", f"/repos/conductor/rune/issues/comments/{existing['id']}",
|
||||
{"body": body})
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(f"SourceLedger publish: update failed (HTTP {status})")
|
||||
return {"action": "updated", "comment_id": existing["id"], "artifact_hash": artifact_hash}
|
||||
|
||||
status, created = _api("POST", f"/repos/conductor/rune/issues/{issue}/comments",
|
||||
{"body": body})
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(f"SourceLedger publish: create failed (HTTP {status}): {created}")
|
||||
return {"action": "created", "comment_id": created.get("id"), "artifact_hash": artifact_hash}
|
||||
|
||||
|
||||
def main_with(argv: list[str]) -> int:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Publish the OUTWARD Source Ledger")
|
||||
parser.add_argument("--issue", type=int, required=True)
|
||||
parser.add_argument("--artifact-dir", required=True)
|
||||
parser.add_argument("--artifact-hash", required=True)
|
||||
args = parser.parse_args(argv[1:])
|
||||
result = publish(args.issue, args.artifact_dir, args.artifact_hash)
|
||||
print(json.dumps(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
raise SystemExit(main_with(sys.argv))
|
||||
@@ -344,6 +344,12 @@ class StageExecutor:
|
||||
"error": "no text output", "stderr": error_detail}
|
||||
artifact = self._normalize_outward_artifact(
|
||||
worker_artifact, relay_result, event)
|
||||
# PROV-1: persisted factual metadata for the Source Ledger
|
||||
artifact["model"] = os.environ.get("MIMO_MODEL", "UNKNOWN")
|
||||
from runner_evidence import capture_execution_evidence, summarize_for_artifact
|
||||
artifact["runner"] = summarize_for_artifact(capture_execution_evidence(
|
||||
stage="OUTWARD", issue=self.issue,
|
||||
process_id=f"research-{self.issue}-r{self.round_no}"))
|
||||
gate_errors = self._validate_outward(artifact)
|
||||
if gate_errors:
|
||||
self.tree.fail("; ".join(gate_errors))
|
||||
@@ -448,6 +454,8 @@ class StageExecutor:
|
||||
"text": artifact_text,
|
||||
"event_counts": relay_result["event_counts"],
|
||||
"elapsed": relay_result["elapsed"],
|
||||
# PROV-1 (#282): persisted factual metadata for the Source Ledger.
|
||||
"model": os.environ.get("MIMO_MODEL", "UNKNOWN"),
|
||||
}
|
||||
|
||||
gate_errors = self._validate_outward(artifact)
|
||||
@@ -1395,6 +1403,7 @@ class StageExecutor:
|
||||
"worker_artifact_file": True,
|
||||
"event_counts": relay_result["event_counts"],
|
||||
"elapsed": relay_result["elapsed"],
|
||||
"model": os.environ.get("MIMO_MODEL", "UNKNOWN"),
|
||||
}
|
||||
|
||||
def _validate_outward(self, artifact: dict) -> list:
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""source_ledger.py — deterministic Source Ledger renderer (PROV-1, #282).
|
||||
|
||||
A thin, boring projection of already-persisted state:
|
||||
accepted OUTWARD artifact + SOURCE_READ/TOOL_FAILED receipts
|
||||
+ persisted run metadata (runner, model)
|
||||
→ pure deterministic render → fixed Markdown template
|
||||
→ idempotent Gitea issue comment (caller's responsibility)
|
||||
|
||||
Hard invariants:
|
||||
- READ is impossible without a successful machine receipt (SOURCE_READ with
|
||||
matching URL). BLOCKED/FAILED sources may appear in the ledger but can
|
||||
never support accepted evidence.
|
||||
- runner/model come from persisted factual fields only; UNKNOWN if absent.
|
||||
Never reconstructed from session memory or timing records.
|
||||
- requested_url and resolved_url both retained.
|
||||
- claim relationships use explicit IDs when present; for pre-#282 artifacts
|
||||
the nesting of claims under sources_read is used and is labeled
|
||||
"derived (legacy nesting)" in the rendered output.
|
||||
- renderer failure raises visibly — provenance publication failing loudly
|
||||
must not silently falsify the ledger.
|
||||
- No trust scores. Factual source classification only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
STATUS_READ = "READ"
|
||||
STATUS_BLOCKED = "BLOCKED"
|
||||
STATUS_FAILED = "FAILED"
|
||||
STATUS_NOT_SELECTED = "NOT_SELECTED"
|
||||
|
||||
|
||||
def _domain(url: str) -> str:
|
||||
try:
|
||||
return urlparse(url).netloc or "unknown"
|
||||
except (ValueError, AttributeError):
|
||||
# Malformed URL — domain is unknown, the row still renders
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _receipt_id(stage: str, tool: str, url: str, ts, seq: int) -> str:
|
||||
"""Deterministic receipt identity: hash of stage + tool + canonical URL +
|
||||
timestamp + event sequence (NOT ts+url alone)."""
|
||||
canonical = url.rstrip("/")
|
||||
raw = f"{stage}|{tool}|{canonical}|{ts}|{seq}"
|
||||
return "R-" + hashlib.sha256(raw.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _norm(url: str) -> str:
|
||||
return (url or "").rstrip("/").lower()
|
||||
|
||||
|
||||
def build_records(outward: dict, stage: str = "OUTWARD") -> list[dict]:
|
||||
"""Pure projection: accepted OUTWARD artifact + receipts → ledger records.
|
||||
Raises on structurally invalid input (renderer failure is visible)."""
|
||||
if not outward or not isinstance(outward, dict):
|
||||
raise ValueError("SourceLedger: no accepted OUTWARD artifact")
|
||||
discovery = outward.get("discovery", {})
|
||||
sources = discovery.get("sources_discovered", [])
|
||||
reading = outward.get("reading", {}).get("sources_read", [])
|
||||
receipts = outward.get("tool_receipts", []) or outward.get("reading", {}).get("tool_receipts", [])
|
||||
|
||||
# Receipt join keys: successful reads by normalized URL
|
||||
read_urls: dict[str, dict] = {}
|
||||
failed_urls: dict[str, dict] = {}
|
||||
for i, r in enumerate(receipts):
|
||||
url = _norm(r.get("url", ""))
|
||||
if not url:
|
||||
continue
|
||||
rid = _receipt_id(stage, r.get("tool", "?"), r.get("url", ""),
|
||||
r.get("ts", 0), i)
|
||||
if r.get("kind") == "SOURCE_READ":
|
||||
read_urls[url] = {"receipt_id": rid, "tool": r.get("tool", "?"), "ts": r.get("ts")}
|
||||
elif r.get("kind") == "TOOL_FAILED":
|
||||
failed_urls[url] = {"receipt_id": rid, "tool": r.get("tool", "?"), "ts": r.get("ts")}
|
||||
|
||||
# Claim → source: explicit if artifact carries claim source_ids, else
|
||||
# legacy nesting (claims are children of sources_read entries).
|
||||
claims_by_source: dict[str, list[str]] = {}
|
||||
legacy = False
|
||||
for s in reading:
|
||||
sid = s.get("id")
|
||||
for c in s.get("claims", []):
|
||||
claims_by_source.setdefault(sid, []).append(c["id"])
|
||||
legacy = True
|
||||
|
||||
# Contradiction links: explicit contradicts_source_ids if present;
|
||||
# otherwise no deterministic derivation (labeled honestly).
|
||||
contradictions_by_source: dict[str, list[str]] = {}
|
||||
for i, c in enumerate(outward.get("contradictory_evidence", outward.get("contradictions", []))):
|
||||
if isinstance(c, dict):
|
||||
for sid in c.get("contradicts_source_ids", []):
|
||||
contradictions_by_source.setdefault(sid, []).append(c.get("id", f"C{i+1}"))
|
||||
# plain-string contradictions are not linked (labeled in render)
|
||||
|
||||
records = []
|
||||
for s in sources:
|
||||
sid = s.get("id", "?")
|
||||
url = s.get("url", "")
|
||||
nurl = _norm(url)
|
||||
# Status precedence: machine receipt is authoritative. A source the
|
||||
# artifact CLAIMS was read but with no SOURCE_READ receipt cannot
|
||||
# render as READ.
|
||||
if nurl in read_urls:
|
||||
status, receipt = STATUS_READ, read_urls[nurl]
|
||||
elif nurl in failed_urls:
|
||||
status, receipt = STATUS_FAILED, failed_urls[nurl]
|
||||
elif s.get("read_receipt"):
|
||||
# Artifact asserts read but no machine receipt matched — honest
|
||||
# downgrade with provenance note rather than silent READ.
|
||||
status, receipt = STATUS_FAILED, None
|
||||
else:
|
||||
# Never attempted: not selected for reading (distinct from BLOCKED,
|
||||
# which implies an attempted fetch that was refused)
|
||||
status, receipt = STATUS_NOT_SELECTED, None
|
||||
# Upgrade precedence fix is above; this block is intentionally after
|
||||
# status resolution so the downgrade applies regardless of note text.
|
||||
resolved = s.get("resolved_url", url) # explicit field if present
|
||||
records.append({
|
||||
"receipt_id": (receipt or {}).get("receipt_id", ""),
|
||||
"source_id": sid,
|
||||
"requested_url": url,
|
||||
"resolved_url": resolved,
|
||||
"domain": _domain(resolved),
|
||||
"status": status,
|
||||
"tool": (receipt or {}).get("tool", s.get("tool", "UNKNOWN_NOT_EXPOSED")),
|
||||
"timestamp": (receipt or {}).get("ts", "UNKNOWN_NOT_EXPOSED"),
|
||||
"source_type": s.get("class", s.get("source_class", "UNKNOWN_NOT_EXPOSED")),
|
||||
"claim_ids": claims_by_source.get(sid, []),
|
||||
"contradiction_ids": contradictions_by_source.get(sid, []),
|
||||
"blocked_reason": (s.get("note", "") if status == STATUS_BLOCKED else
|
||||
(receipt.get("kind") if status == STATUS_FAILED and receipt else
|
||||
("no machine SOURCE_READ receipt matched (legacy artifact)" if status == STATUS_FAILED else ""))),
|
||||
"supports_evidence": status == STATUS_READ,
|
||||
})
|
||||
return records
|
||||
|
||||
|
||||
def render_markdown(records: list[dict], outward: dict, runner: dict | None,
|
||||
model: str, artifact_hash: str, legacy_claims: bool = False) -> str:
|
||||
"""Fixed Markdown template. Pure function of records + metadata."""
|
||||
d = outward.get("discovery", {})
|
||||
n_read = sum(1 for r in records if r["status"] == STATUS_READ)
|
||||
n_blocked = sum(1 for r in records if r["status"] == STATUS_BLOCKED)
|
||||
n_failed = sum(1 for r in records if r["status"] == STATUS_FAILED)
|
||||
n_claims = sum(len(r["claim_ids"]) for r in records)
|
||||
n_contra = len(outward.get("contradictory_evidence", outward.get("contradictions", [])))
|
||||
runner_name = (runner or {}).get("runner_name", "UNKNOWN")
|
||||
runner_host = (runner or {}).get("runner_hostname", "UNKNOWN")
|
||||
prov_note = ""
|
||||
if legacy_claims:
|
||||
prov_note = ("\n*Claim↔source links for this legacy artifact are derived from "
|
||||
"claim nesting (pre-#282 format); future artifacts carry explicit "
|
||||
"supports_source_ids/contradicts_source_ids.*")
|
||||
|
||||
lines = [
|
||||
"## OUTWARD — ACCEPTED",
|
||||
f"- artifact hash: `{artifact_hash}`",
|
||||
f"- runner: {runner_name} ({runner_host})",
|
||||
f"- model: {model}",
|
||||
f"- queries: {len(d.get('queries', []))}",
|
||||
f"- sources discovered: {len(records)}",
|
||||
f"- sources actually read (machine receipt): {n_read}",
|
||||
f"- blocked: {n_blocked} | failed fetch: {n_failed}",
|
||||
f"- evidence claims: {n_claims} | contradictions: {n_contra}",
|
||||
"",
|
||||
"## SOURCE LEDGER",
|
||||
"",
|
||||
"| ID | source/domain | type | status | E-claims | contradictions | caveat |",
|
||||
"|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for r in records:
|
||||
caveat = r["blocked_reason"] or ""
|
||||
if r["status"] != STATUS_READ:
|
||||
caveat = (caveat + " — backs ZERO accepted claims").strip(" —")
|
||||
link = f"[{r['domain']}]({r['requested_url']})" if r["requested_url"].startswith("http") else r["domain"]
|
||||
lines.append(
|
||||
f"| {r['source_id']} | {link} | {r['source_type']} | {r['status']} "
|
||||
f"| {len(r['claim_ids'])} | {len(r['contradiction_ids'])} | {caveat[:60]} |")
|
||||
lines.append(prov_note)
|
||||
return "\n".join(lines)
|
||||
@@ -3,4 +3,4 @@
|
||||
This is the main entry point for the Rune program.
|
||||
"""
|
||||
|
||||
__version__ = "0.59.4"
|
||||
__version__ = "0.59.5"
|
||||
@@ -252,7 +252,7 @@ def main() -> int:
|
||||
)
|
||||
check(
|
||||
"Rune minor version advertises provisioning and restore verification",
|
||||
'__version__ = "0.59.4"' in rune_version,
|
||||
'__version__ = "0.59.5"' in rune_version,
|
||||
failures,
|
||||
)
|
||||
# #169 policy update: auto-bump on merge is sanctioned (Nick directive:
|
||||
|
||||
@@ -7,7 +7,7 @@ mode system, subject header, shared memory and identity.
|
||||
|
||||
This is not an interface. This is how I see, speak, and interact with the world.
|
||||
|
||||
Version: v0.59.4
|
||||
Version: v0.59.5
|
||||
Features: 41 (24 original + 17 new native commands)
|
||||
|
||||
Usage:
|
||||
@@ -1923,7 +1923,7 @@ class RuneApp(App):
|
||||
# Show version info
|
||||
self.chat_channel.add_message(
|
||||
"system",
|
||||
f"[dim]Rune v0.59.4 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
f"[dim]Rune v0.59.5 | Textual TUI | {len(loaded)} native modules[/dim]",
|
||||
)
|
||||
|
||||
# Auto-show night mode suggestion if evening/night hours
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Tests: Source Ledger deterministic renderer (#282 / PROV-1).
|
||||
|
||||
Invariants:
|
||||
- READ impossible without a successful machine receipt
|
||||
- BLOCKED/FAILED appear but back zero accepted claims
|
||||
- deterministic receipt_id (same inputs → same ID)
|
||||
- runner/model from persisted fields only; UNKNOWN if absent
|
||||
- requested_url + resolved_url both retained
|
||||
- legacy nesting labeled; explicit IDs used when present
|
||||
- render is a pure function (same input → same output)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
spec = importlib.util.spec_from_file_location("source_ledger", ROOT / "scripts" / "source_ledger.py")
|
||||
sl = importlib.util.module_from_spec(spec)
|
||||
sys.modules["source_ledger"] = sl
|
||||
spec.loader.exec_module(sl)
|
||||
|
||||
|
||||
def _artifact(**overrides):
|
||||
a = {
|
||||
"discovery": {
|
||||
"queries": [{"id": "Q1", "query": "q", "receipt": "ok"}],
|
||||
"sources_discovered": [
|
||||
{"id": "O1", "url": "https://example.org/read-me", "class": "official docs"},
|
||||
{"id": "O2", "url": "https://blocked.example/x", "class": "forum",
|
||||
"note": "BLOCKED: bot challenge"},
|
||||
{"id": "O3", "url": "https://failed.example/y", "class": "blog"},
|
||||
],
|
||||
"selected_for_reading": ["O1"],
|
||||
},
|
||||
"reading": {"sources_read": [{"id": "O1", "url": "https://example.org/read-me",
|
||||
"claims": [{"id": "E1", "claim": "x"}]}]},
|
||||
"tool_receipts": [
|
||||
{"kind": "SOURCE_READ", "tool": "webfetch", "url": "https://example.org/read-me", "ts": 100},
|
||||
{"kind": "TOOL_FAILED", "tool": "webfetch", "url": "https://failed.example/y", "ts": 101},
|
||||
],
|
||||
"contradictory_evidence": [
|
||||
{"id": "C1", "topic": "t", "resolution": "r",
|
||||
"contradicts_source_ids": ["O1"]},
|
||||
],
|
||||
}
|
||||
a.update(overrides)
|
||||
return a
|
||||
|
||||
|
||||
def test_read_requires_machine_receipt():
|
||||
"""THE safety invariant: READ only with a SOURCE_READ receipt."""
|
||||
records = sl.build_records(_artifact())
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["status"] == "READ"
|
||||
assert by_id["O1"]["receipt_id"].startswith("R-")
|
||||
assert by_id["O1"]["supports_evidence"] is True
|
||||
|
||||
|
||||
def test_artifact_claimed_read_without_receipt_downgrades():
|
||||
"""A source claiming read with no matching machine receipt → FAILED,
|
||||
never silently READ."""
|
||||
a = _artifact(tool_receipts=[]) # no receipts at all
|
||||
# Artifact-asserts-read lives on the discovery source entry (real shape)
|
||||
a["discovery"]["sources_discovered"][0]["read_receipt"] = True
|
||||
records = sl.build_records(a)
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["status"] == "FAILED"
|
||||
assert by_id["O1"]["supports_evidence"] is False
|
||||
|
||||
|
||||
def test_blocked_and_failed_back_zero_claims():
|
||||
records = sl.build_records(_artifact())
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O2"]["status"] == "NOT_SELECTED"
|
||||
assert by_id["O3"]["status"] == "FAILED"
|
||||
for r in records:
|
||||
if r["status"] != "READ":
|
||||
assert r["claim_ids"] == []
|
||||
assert r["supports_evidence"] is False
|
||||
|
||||
|
||||
def test_receipt_id_deterministic():
|
||||
r1 = sl._receipt_id("OUTWARD", "webfetch", "https://x.example/", 100, 0)
|
||||
r2 = sl._receipt_id("OUTWARD", "webfetch", "https://x.example/", 100, 0)
|
||||
r3 = sl._receipt_id("OUTWARD", "webfetch", "https://x.example/", 100, 1)
|
||||
assert r1 == r2 and r1 != r3
|
||||
|
||||
|
||||
def test_requested_and_resolved_urls_retained():
|
||||
a = _artifact()
|
||||
a["discovery"]["sources_discovered"][0]["resolved_url"] = "https://resolved.example/read-me"
|
||||
records = sl.build_records(a)
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["requested_url"] == "https://example.org/read-me"
|
||||
assert by_id["O1"]["resolved_url"] == "https://resolved.example/read-me"
|
||||
assert by_id["O1"]["domain"] == "resolved.example"
|
||||
|
||||
|
||||
def test_claim_and_contradiction_ids():
|
||||
records = sl.build_records(_artifact())
|
||||
by_id = {r["source_id"]: r for r in records}
|
||||
assert by_id["O1"]["claim_ids"] == ["E1"]
|
||||
assert by_id["O1"]["contradiction_ids"] == ["C1"]
|
||||
|
||||
|
||||
def test_legacy_nesting_flagged_in_render():
|
||||
md = sl.render_markdown(sl.build_records(_artifact()), _artifact(),
|
||||
{"runner_name": "r", "runner_hostname": "h"},
|
||||
"test-model", "abc123", legacy_claims=True)
|
||||
assert "legacy artifact" in md or "pre-#282" in md
|
||||
|
||||
|
||||
def test_render_pure_function():
|
||||
a = _artifact()
|
||||
recs = sl.build_records(a)
|
||||
md1 = sl.render_markdown(recs, a, {"runner_name": "rune-desktop",
|
||||
"runner_hostname": "nick-desktop"},
|
||||
"glm-5.3-flash", "hash1")
|
||||
md2 = sl.render_markdown(recs, a, {"runner_name": "rune-desktop",
|
||||
"runner_hostname": "nick-desktop"},
|
||||
"glm-5.3-flash", "hash1")
|
||||
assert md1 == md2
|
||||
|
||||
|
||||
def test_unknown_metadata_not_reconstructed():
|
||||
md = sl.render_markdown(sl.build_records(_artifact()), _artifact(),
|
||||
None, "UNKNOWN", "hash1")
|
||||
assert "UNKNOWN" in md
|
||||
# runner None → UNKNOWN, never guessed
|
||||
assert "rune-desktop" not in md
|
||||
|
||||
|
||||
def test_empty_artifact_raises_visibly():
|
||||
try:
|
||||
sl.build_records({})
|
||||
raise AssertionError("should have raised")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_full_render_smoke():
|
||||
md = sl.render_markdown(sl.build_records(_artifact()), _artifact(),
|
||||
{"runner_name": "rune-desktop",
|
||||
"runner_hostname": "nick-desktop"},
|
||||
"glm-5.3-flash", "abc123")
|
||||
assert "OUTWARD — ACCEPTED" in md
|
||||
assert "SOURCE LEDGER" in md
|
||||
assert "READ" in md and "NOT_SELECTED" in md and "FAILED" in md
|
||||
assert "backs ZERO accepted claims" in md
|
||||
|
||||
|
||||
# ── Idempotent publication (#282 constraint) ────────────────────────────────
|
||||
|
||||
def _publish_mod():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"publish_source_ledger", ROOT / "scripts" / "publish_source_ledger.py")
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def test_idempotent_publish_updates_not_duplicates(tmp_path, monkeypatch):
|
||||
"""Same artifact hash → second publish UPDATES the existing comment."""
|
||||
plm = _publish_mod()
|
||||
a = _artifact()
|
||||
(tmp_path / "outward.json").write_text(json.dumps(a))
|
||||
calls = {"posted": [], "patched": []}
|
||||
|
||||
class FakeResp:
|
||||
def __init__(self, payload, status=200):
|
||||
self._p, self.status = payload, status
|
||||
def read(self):
|
||||
return json.dumps(self._p).encode()
|
||||
def fake_api(method, path, payload=None):
|
||||
if path.endswith("/comments?limit=50"):
|
||||
return 200, [{"id": 1, "body": "<!-- source-ledger hash=abc123 -->\nold body"}]
|
||||
if method == "PATCH" and "/comments/" in path:
|
||||
calls["patched"].append((path, payload["body"][:40]))
|
||||
return 200, {"id": 1}
|
||||
if method == "POST" and path.endswith("/comments"):
|
||||
calls["posted"].append(payload["body"][:40])
|
||||
return 201, {"id": 99}
|
||||
return 200, {}
|
||||
monkeypatch.setattr(plm, "_api", fake_api)
|
||||
|
||||
r1 = plm.publish(999, str(tmp_path), "abc123")
|
||||
assert r1["action"] == "updated" and r1["comment_id"] == 1
|
||||
assert not calls["posted"], "must UPDATE, not create a duplicate"
|
||||
r2 = plm.publish(999, str(tmp_path), "abc123")
|
||||
assert r2["action"] == "updated"
|
||||
assert len(calls["posted"]) == 0
|
||||
|
||||
|
||||
def test_new_hash_creates_new_comment(tmp_path, monkeypatch):
|
||||
plm = _publish_mod()
|
||||
(tmp_path / "outward.json").write_text(json.dumps(_artifact()))
|
||||
posted = []
|
||||
def fake_api(method, path, payload=None):
|
||||
if path.endswith("/comments?limit=50"):
|
||||
return 200, []
|
||||
if method == "POST" and path.endswith("/comments"):
|
||||
posted.append(payload["body"][:50])
|
||||
return 201, {"id": 7}
|
||||
return 200, {}
|
||||
import unittest.mock as mock
|
||||
monkeypatch.setattr(plm, "_api", fake_api)
|
||||
r = plm.publish(999, str(tmp_path), "newhash")
|
||||
assert r["action"] == "created" and len(posted) == 1
|
||||
|
||||
|
||||
def test_publish_failure_raises_visibly(tmp_path, monkeypatch):
|
||||
"""Provenance publication failure must be VISIBLE, not swallowed."""
|
||||
plm = _publish_mod()
|
||||
(tmp_path / "outward.json").write_text(json.dumps(_artifact()))
|
||||
def fake_api(method, path, payload=None):
|
||||
if path.endswith("/comments?limit=50"):
|
||||
return 500, "server error"
|
||||
return 200, {}
|
||||
import unittest.mock as mock
|
||||
monkeypatch.setattr(plm, "_api", fake_api)
|
||||
with pytest.raises(RuntimeError, match="cannot list comments"):
|
||||
plm.publish(999, str(tmp_path), "hash1")
|
||||
Reference in new issue
Block a user